سالیدیتی با مثال
رای گیری
قرارداد زیر کاملاً پیچیده است، اما بسیاری از ویژگیهای سالیدیتی را به نمایش میگذارد. قرارداد رای گیری را اجرا میکند. البته، مشکلات اصلی رای گیری الکترونیکی نحوه واگذاری حق رای به افراد صحیح و نحوه جلوگیری از دستکاری است. ما همه مشکلات را در اینجا حل نخواهیم کرد، اما حداقل نشان خواهیم داد که چگونه میتوان رای گیریِ نمایندگان را انجام داد تا شمارش آراء در همان زمان به صورت خودکار و کاملاً شفاف انجام شود
ایده این است که یک قرارداد برای هر رأی ایجاد شود و نام کوتاهی برای هر گزینه ارائه شود. سپس خالق قرارداد که به عنوان رئیس فعالیت میکند به هر آدرس به طور جداگانه حق رای میدهد.
افراد پشت آدرسها میتوانند انتخاب کنند که یا خود رأی دهند یا رای خود را به شخصی که به او اعتماد دارند واگذار کنند.
در پایان زمان رای گیری، ()winningProposal پیشنهاد را با بیشترین تعداد رای برمیگرداند.
// 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; } }
بهبودهای احتمالی
در حال حاضر، بسیاری از تراکنش ها برای واگذاری حق رای به همه شرکت کنندگان مورد نیاز است. علاوه بر این، اگر دو یا چند پروپوزال دارای تعداد آرای یکسانی باشند، ()winningProposal قادر به ثبت تساوی نیست. آیا میتوانید راهی برای رفع این مشکلات فکر کنید؟
مزایده کور
در این بخش، نشان خواهیم داد که ایجاد یک قرارداد حراجِ کاملا کور در اتریوم چقدر آسان است. ما با یک مزایده باز شروع میکنیم که در آن همه میتوانند پیشنهادات ارائه شده را ببینند و سپس این قرارداد را به حراج کور توسعه میدهیم که در آن امکان مشاهده پیشنهاد واقعی تا زمان پایان مناقصه وجود ندارد.
مزایده باز ساده
ایدهی کلی قرارداد مزایده ساده زیر این است که همه میتوانند پیشنهادات خود را در طول یک دوره مناقصه ارسال کنند. پیشنهادات در حال حاضر شامل ارسال پول/ اتر به منظور متصل کردن مناقصهگرانِ مناقصه به پیشنهاد آنها است. اگر بالاترین پیشنهاد افزایش یابد، مناقصهگران قبلی پول خود را پس میگیرد. پس از پایان دوره مناقصه، قرارداد باید به صورت دستی فراخوانده شود تا ذینفع پول خود را دریافت کند- قراردادها نمیتوانند خودشان فعال شوند.
// 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 // money back (the revert statement // will revert all changes in this // function execution including // it having received the money). if (msg.value <= highestBid) revert BidNotHighEnough(highestBid); if (highestBid != 0) { // Sending back the money 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 money 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 `send` 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 `send()`. if (!payable(msg.sender).send(amount)) { // 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 beneficiary.transfer(highestBid); } }
مزایده کور
مزایده باز قبلی در ادامه به مزایده کور توسعه یافتهاست. مزیت مزایده کور این است که هیچ گونه فشار زمانی نسبت به پایان دوره مناقصه وجود ندارد. ایجاد مزایده کور بر روی یک پلتفرم محاسباتی شفاف ممکن است متناقض به نظر برسد، اما رمزنگاری به کمک شما میآید.
در طول دوره مناقصه، یک پیشنهاد دهنده در واقع پیشنهاد خود را ارسال نمیکند، بلکه فقط یک نسخه هش شده از آن را ارسال میکند. از آنجا که در حال حاضر یافتن دو مقدار (به اندازه کافی طولانی) که مقادیر هش آنها برابر باشد، عملاً غیرممکن تلقی میشود، مناقصهگر با این کار متعهد به مناقصه میشود. پس از پایان دوره مناقصه، مناقصهگران باید پیشنهادات خود را آشکار کنند. آنها مقادیر خود را بدون رمزگذاری ارسال میکنند و قرارداد بررسی میکند که مقدار هش همان مقدار ارائه شده در دوره مناقصه است.
چالش دیگر این است که چگونه مزایده را به طور همزمان اجباری و پنهان یا کور جلوه دهید: تنها راه جلوگیری از ارسال نکردن مبلغ توسط داوطلب پس از برنده شدن در مزایده، ارسال آنها به همراه پیشنهاد است. از آنجا که انتقال مقدار در اتریوم پنهان یا کور نمیشود، هر کسی میتواند مقدار را ببیند.
قرارداد زیر با قبول هر مقداری که بزرگتر از بالاترین پیشنهاد باشد، این مشکل را حل میکند. از آنجایی که این فقط در مرحله آشکار شدن قابل بررسی است، ممکن است برخی از پیشنهادات نامعتبر باشند، و این هدفمند است (حتی یک فلَگ صریح برای قرار دادن پیشنهادات نامعتبر با انتقال مقدار بالا ارائه میدهد): مناقصهگران با قرار دادن چند پیشنهاد زیاد یا کم اعتبار، میتوانند رقابت را مختل کنند.
// 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); } payable(msg.sender).transfer(refund); } /// 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 `transfer` returns (see the remark above about // conditions -> effects -> interaction). pendingReturns[msg.sender] = 0; payable(msg.sender).transfer(amount); } } /// 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; beneficiary.transfer(highestBid); } // 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; } }
خرید امن از راه دور
خرید کالا از راه دور در حال حاضر نیاز به چندین طرف دارد که باید به یکدیگر اعتماد کنند. ساده ترین پیکربندی شامل یک فروشنده و یک خریدار است. خریدار مایل است کالایی را از فروشنده دریافت کند و فروشنده مایل است در ازای آن پول (یا معادل آن) دریافت کند. قسمت مشکل ساز محموله در اینجا است: هیچ راهی برای تعیین اطمینان از رسیدن کالا به خریدار وجود ندارد.
روشهای مختلفی برای حل این مشکل وجود دارد، اما همه آنها در مقابل یک یا بقیه راهها کم میآورند. در مثال زیر، هر دو طرف باید مقدار دو برابر یک قلم کالا را به عنوان ضمانت در قرارداد قرار دهند. به محض اینکه این اتفاق افتاد، پول در قرارداد قفل شده خواهد ماند تا زمانی که خریدار تأیید کند که کالا را دریافت کردهاست. پس از آن، مقدار (نیمی از سپرده خود) به خریدار بازگردانده میشود و فروشنده سه برابر مقدار (سپرده خود به علاوه مقدار) دریافت میکند. ایده پشت این امر این است که هر دو طرف انگیزهای برای حل اوضاع دارند یا در غیر این صورت پول آنها برای همیشه قفل شده خواهد ماند.
البته این قرارداد مشکلی را حل نمیکند، اما یک نمای کلی از چگونگی استفاده از ساختار ماشین حالت مانند در داخل قرارداد ارائه میدهد.
// 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 transfer here directly. It is // reentrancy-safe, because it is the // last call in this function and we // already changed the state. seller.transfer(address(this).balance); } /// 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 `send` below // can call in again here. state = State.Release; buyer.transfer(value); } /// 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 `send` below // can call in again here. state = State.Inactive; seller.transfer(3 * value); } }
کانال پرداخت خرد
در این بخش نحوه ساختن نمونه پیاده سازی کانال پرداخت را خواهیم آموخت. کانال پرداخت از امضاهای رمزنگاری شده برای انتقال مکرر اتر بین طرفهای مشابه به صورت ایمن، آنی و بدون کارمزد استفاده میکند. برای مثال، باید نحوه امضا و تأیید امضاها و راه اندازی کانال پرداخت را درک کنیم.
ایجاد و تأیید امضا
تصور کنید آلیس میخواهد مقداری اتر برای باب ارسال کند، یعنی آلیس فرستنده است و باب گیرنده آن میباشد.
آلیس فقط باید پیام های خارج از زنجیرهِ امضا شده با رمزنگاری (مثلاً از طریق ایمیل) را به باب بفرستد و این شبیه چک نوشتن است.
آلیس و باب برای تأیید تراکنشها از امضاها استفاده میکنند که با قراردادهای هوشمند در اتریوم امکان پذیر است. آلیس یک قرارداد هوشمند ساده خواهد ساخت که به او امکان میدهد اتر را منتقل کند، اما به جای اینکه خودش یک تابع را برای شروع پرداخت فراخوانی کند، به باب اجازه این کار را میدهد و بنابراین هزینه تراکنش را پرداخت میکند. قرارداد به شرح زیر کار میکند:
- آلیس قرارداد ReceiverPays را دیپلوی میکند، به اندازه کافی اتر را برای پوشش پرداختهایی که انجام خواهد شد، پیوست میکند.
- آلیس با امضای پیام با کلید خصوصی خود اجازه پرداخت را میدهد.
- آلیس پیام امضا شده با رمزنگاری را برای باب میفرستد. نیازی به مخفی نگه داشتن پیام نیست (بعداً توضیح داده خواهد شد) و سازوکار ارسال آن اهمیتی ندارد.
- باب با ارائه پیام امضا شده به قرارداد هوشمند، پرداخت خود را مدعی میشود. قرارداد صحت پیام را تأیید میکند و سپس وجوه را آزاد میکند.
ایجاد امضا
آلیس برای امضای تراکنش نیازی به تعامل با شبکه اتریوم ندارد، روند کار کاملا آفلاین است. در این آموزش، ما با استفاده از روش توصیف شده در 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"); });
توجه داشته باشید
web3.eth.personal.sign طول پیام را به دادههای امضا شده اضافه میکند. از آنجا که ما ابتدا هش میکنیم، پیام همیشه دقیقاً 32 بایت خواهد بود و بنابراین این پیشوند طول همیشه یکسان است.
چه چیزی را امضا کنیم
برای قراردادی که پرداختها را انجام میدهد، پیام امضا شده باید شامل موارد زیر باشد:
- آدرس گیرنده.
- مبلغی که باید منتقل شود.
- محافظت در برابر حملات مجدد.
حمله مجدد زمانی رخ میدهد که از پیام امضا شده مجدداً برای درخواست مجوز برای اقدام دیگر استفاده میشود. برای جلوگیری از حملات مجدد، ما از همان روش تراکنش اتریوم استفاده میکنیم، اصطلاحاً نانس نامیده میشود، یعنی تعداد تراکنشهای ارسال شده توسط یک حساب میباشد. قرارداد هوشمند بررسی میکند که آیا یک نانس چندین بار استفاده شدهاست. نوع دیگر حمله مجدد میتواند هنگامی رخ دهد که مالکِ قرارداد هوشمند ReceiverPays را دیپلوی کند، مقداری پرداخت انجام بدهد و سپس قرارداد را از بین ببرد. بعداً، آنها تصمیم میگیرند که قرارداد هوشمند RecipientPays را دوباره دیپلوی کنند، اما قرارداد جدید، نانس استفاده شده در دیپلوی قبلی را نمیشناسد، بنابراین مهاجم میتواند دوباره از پیامهای قدیمی استفاده کند. آلیس میتواند با درج آدرس قرارداد در پیام در برابر این حمله محافظت کند و فقط پیامهای حاوی آدرس قرارداد خود پذیرفته میشوند. نمونهای از این مورد را میتوانید در دو خط اول تابع ()claimPayment در قرارداد کامل در انتهای این بخش بیابید.
بسته بندی آرگومانها
حالا که ما مشخص کردهایم که چه اطلاعاتی را باید در پیام امضا شده قرار دهیم، ما آماده هستیم که پیام را کنار هم قرار دهیم و آن را هش و امضا کنیم. برای سادگی، دادهها را بهم پیوند میدهیم. کتابخانه ethereumjs-abi تابعی به نام soliditySHA3 را فراهم میکند که رفتار keccak256 سالیدیتی را که برای آرگومانهای رمزگذاری شده با استفاده از abi.encodePacked اعمال میشود، را تقلید میکند. در اینجا یک تابع جاوا اسکریپت وجود دارد که امضای مناسب را برای مثال ReceiverPays ایجاد میکند:
// 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
به طور کلی، امضاهای ECDSA از دو پارامتر r و s تشکیل شده است. امضاها در اتریوم شامل پارامتر سومی به نام v هستند که میتوانید از آن برای تأیید اینکه از کلید خصوصی حساب برای امضای پیام استفاده شده است و فرستنده تراکنش استفاده کنید. Solidity یک ecrecover تابع داخلی را ارائه میدهد که پیامی را به همراه پارامترهای r ، s و v میپذیرد و آدرسی را که برای امضای پیام استفاده شده است، برمیگرداند.
استخراج پارامترهای امضا
امضاهای تولید شده توسط web3.js ترکیبی از r ، s و v هستند، بنابراین اولین قدم این است که این پارامترها را از هم جدا کنیم. شما می توانید این کار را در سمت مشتری انجام دهید، اما انجام آن در داخل قرارداد هوشمند به این معنی است که به جای سه پارامتر، فقط باید یک پارامتر امضا ارسال کنید. تفکیک یک آرایه بایتی به اجزای تشکیل دهنده آن مشکل است، بنابراین ما از اسمبلی درون خطی برای انجام کار در تابع splitSignature (سومین تابع در قرارداد کامل در انتهای این بخش) استفاده می کنیم.
محاسبه پیام هش
قرارداد هوشمند باید دقیقاً بداند چه پارامترهایی امضا شدهاند، بنابراین باید پیام را از طریق پارامترها دوباره ایجاد کند و از آن برای تأیید امضا استفاده کند. توابع prefixed و recoverSigner این کار را در تابع claimPayment انجام میدهند.
قرار داد کامل
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; 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)); } }
نوشتن یک کانال پرداخت ساده
اکنون آلیس یک پیاده سازی ساده اما کامل از یک کانال پرداخت ایجاد کردهاست. کانالهای پرداخت از امضاهای رمزنگاری شده برای انتقال مکرر اتر به صورت ایمن، فوری و تراکنش بدون کارمزد استفاده میکنند.
کانال پرداخت چیست؟
کانالهای پرداخت به شرکت کنندگان این امکان را میدهد تا انتقالهای مکرر اتر را بدون استفاده از تراکنش انجام دهند. این بدان معنی است که شما میتوانید از تأخیر و هزینههای مرتبط با تراکنشها جلوگیری کنید. ما میخواهیم یک کانال پرداخت ساده بین دو طرف (آلیس و باب) را بررسی کنیم. شامل سه مرحله است:
- آلیس قرارداد هوشمند همراه با اتر را تأمین میکند. این کانال پرداخت را “باز” میکند.
- آلیس پیامهایی را امضا میکند که مشخص میکند چه مقدار از آن اتر به گیرنده بدهکار است. این مرحله برای هر پرداخت تکرار میشود.
- باب کانال پرداخت را “می بندد”، سهم خود را از اتر پس میگیرد و باقیمانده را به فرستنده ارسال میکند.
توجه داشته باشید
فقط مراحل 1 و 3 به تراکنشهای اتریوم نیاز دارند، مرحله 2 به این معنی است که فرستنده از طریق روشهای غیر زنجیرهای (به عنوان مثال ایمیل) پیام امضا شده رمزنگاری شده را به گیرنده منتقل میکند. این بدان معناست که برای پشتیبانی از هر تعداد تراکنش فقط دو تراکنش لازم است.
تضمین شده که باب وجوه خود را دریافت میکند زیرا قرارداد هوشمند اتر را اسکو میکند (اسکو به معنی اینکه طرفین معامله بر یک سری شرایط توافق میکنند که اگر این شرایط توسط دو طرفین انجام شوند طرف ثالث مانند قرارداد هوشمند امکان برداشت یا پرداخت وجوه را امکان پذیر میکند.) و قول یک پیام معتبر امضا شده را میدهد. قرارداد هوشمند همچنین مهلت زمانی را اعمال میکند، بنابراین آلیس تضمین میکند که سرانجام وجوه خود را بازیابی میکند حتی اگر گیرنده از بستن کانال خودداری کند. شرکت کنندگان در یک کانال پرداخت تصمیم میگیرند که چه مدت آن را باز نگه دارند. برای یک معامله کوتاه مدت، مانند پرداخت کافینت به ازای هر دقیقه دسترسی به شبکه، کانال پرداخت ممکن است به مدت محدودی باز نگه داشته شود. از طرف دیگر، برای پرداخت مکرر، مانند پرداخت دستمزد ساعتی به یک کارمند، کانال پرداخت ممکن است برای چندین ماه یا سال باز نگه داشته شود.
باز کردن کانال پرداخت
برای باز کردن کانال پرداخت، آلیس قرارداد هوشمند را دیپلوی میکند، اتر را برای تضمین ضمیمه میکند و دریافت کننده مورد نظر و حداکثر مدت زمان وجود کانال را مشخص میکند. در انتهای این بخش این تابع SimplePaymentChannel در قرارداد است.
ایجاد پرداخت ها
آلیس با ارسال پیامهای امضا شده به باب، پرداختها را انجام میدهد. این مرحله کاملاً خارج از شبکه اتریوم انجام میشود. پیامها به صورت رمزنگاری شده توسط فرستنده امضا میشوند و سپس مستقیماً به گیرنده ارسال میشوند.
هر پیام شامل اطلاعات زیر است:
- آدرسِ قراردادِ هوشمند استفاده شده برای جلوگیری از حملات مجدد بین قراردادی.
- مقدارِ کلِ اتری که تاکنون به گیرنده بدهکار است.
در پایان یک سری انتقالها، فقط یک بار کانال پرداخت بسته میشود. به همین دلیل، فقط یکی از پیامهای ارسالی استفاده میشود. به همین دلیل است که هر پیام مجموع مقدار کل اتر بدهکار را به جای مقدار جداگانه کانال پرداخت مشخص میکند. گیرنده به طور طبیعی آخرین پیام را بخاطر اینکه بالاترین جمع کل را دارد، برای بازخرید انتخاب خواهد کرد. دیگر به نانس برای هر پیام نیاز نمیباشد زیرا قرارداد هوشمند فقط به یک پیام پایبند است. برای جلوگیری از استفاده پیامی که در نظر گرفته شده برای یک کانال پرداخت در کانال دیگر، از آدرس قرارداد هوشمند همچنان استفاده میشود.
در اینجا کد جاوا اسکریپت ویرایش شده برای امضای یک پیام به صورت رمزنگاری از بخش قبلی وجود دارد:
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); }
بستن کانال پرداخت
هنگامی که باب آماده دریافت وجوه خود باشد، وقت آن است که با فراخوانی تابع close در قرارداد هوشمند کانال پرداخت را ببندید. بستن کانال به گیرنده اتری که بدهکار است را پرداخت میکند و قرارداد را از بین میبرد و اتر باقی مانده را برای آلیس میفرستد. برای بستن کانال، باب باید پیامی را امضا کند که توسط آلیس امضا شده باشد. قرارداد هوشمند باید تأیید کند که پیام حاوی یک امضای معتبر از طرف فرستنده است. روند انجام این تأیید همان روندی است که گیرنده از آن استفاده میکند. توابع سالیدیتی isValidSignature و recoverSigner درست همانند رونوشتهای جاوا اسکریپت در بخش قبلی با تابع آخری که از قرارداد ReceiverPays گرفته شده کار میکند.
فقط گیرنده کانال پرداخت میتواند تابع close را فراخوانی کند، که به طور طبیعی جدیدترین پیام پرداخت را ارسال میکند زیرا این پیام بیشترین مجموع بدهی را دارد. اگر فرستنده اجازه فراخوانی این تابع را داشته باشد، میتواند پیامی با مقدار کمتری ارائه دهد و گیرنده را از آنچه طلبکار است، فریب دهد. تابع تأیید میکند که پیام امضا شده با پارامترهای داده شده مطابقت دارد. اگر همه چیز بررسی شود، به گیرنده بخشی از اتر ارسال می شود، و بقیه را از طریق selfdestruct برای فرستنده ارسال میکند. تابع close را میتوانید در قراردادِ کامل مشاهده کنید.
انقضا کانال
باب میتواند در هر زمان کانال پرداخت را ببندد، اما اگر آنها موفق به انجام این کار نشوند، آلیس به راهی برای بازیابی وجوه پس انداز شده خود نیاز دارد. زمان انقضا در زمان استقرار قرارداد تعیین میشود. پس از رسیدن به این زمان، آلیس میتواند با فراخوانی تابع claimTimeout وجوه خود را پس بگیرد. تابع claimTimeout را میتوانید در قرارداد کامل مشاهده کنید. بعد از فراخوانی این تابع، باب دیگر نمیتواند هیچ اتری دریافت کند، بنابراین مهم است که باب قبل از رسیدن به زمان انقضا، کانال را ببندد.
قرارداد کامل
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; contract SimplePaymentChannel { 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 { require(msg.sender == recipient); require(isValidSignature(amount, signature)); recipient.transfer(amount); selfdestruct(sender); } /// the sender can extend the expiration at any time function extend(uint256 newExpiration) external { 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 { 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))); // 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)); } }
توجه داشته باشید
تابع splitSignature از همه بررسیهای امنیتی استفاده نمیکند. برای پیاده سازی واقعی باید از یک کتابخانه تست شده با دقت بیشتری استفاده کرد، مانند نسخه openzepplin از این کد.
تأیید پرداختها
برخلاف بخش قبلی، پیامهای موجود در یک کانال پرداخت بلافاصله استفاده نمیشوند. گیرنده آخرین پیام را پیگیری میکند و هنگامی که زمان بستن کانال پرداخت باشد، آن را استفاده میکند. این به این معنی است که بسیار مهم میباشد که گیرنده تأیید خود را برای هر پیام انجام دهد. در غیر این صورت هیچ تضمینی وجود ندارد که در پایان گیرنده بتواند وجوه را دریافت کند. گیرنده باید هر پیام را با استفاده از روند زیر تأیید کند:
- 1. تأیید کند که آدرس قرارداد در پیام با کانال پرداخت مطابقت دارد.
- تأیید کند که کل جدید ، مقدار مورد انتظار است.
- تأیید کند که کل جدید از مقدار اتر پس انداز شده بیشتر نیست.
- تأیید کند که امضا معتبر است و از طرف فرستنده کانال پرداخت میباشد.
برای نوشتن این تأیید از کتابخانه ethereumjs-util استفاده خواهیم کرد. مرحله آخر را میتوان به روشهای مختلفی انجام داد، و ما از جاوا اسکریپت استفاده میکنیم. کد زیر تابع 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(); }
قراردادهای ماژولی
یک رویکرد ماژولی برای ساخت قراردادها، در کاهش پیچیدگیها و بهبود خوانایی، که به شناسایی باگها و آسیب پذیریها هنگام توسعه و بررسی کد کمک میکند. اگر رفتار یا هر ماژول را جداگانه تعیین و کنترل کنید، فعل و انفعالاتی را باید فقط در روابط بین مشخصات ماژول در نظر بگیرید و نه هر قسمت متحرک دیگر قرارداد.
در مثال زیر، قرارداد از روش move کتابخانه Balances برای بررسی اینکه بالانسهای ارسال شده بین آدرسها با آنچه شما انتظار دارید مطابقت دارد، استفاده میکند. به این ترتیب، کتابخانه 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]; } }