在本節(jié)中,我們將學習如何構建支付渠道的示例實現(xiàn)。它使用加密簽名在同一方之間的重復以太幣轉(zhuǎn)移安全、即時且無需交易費用。
例如,我們需要了解如何簽名和驗證簽名,以及設置支付渠道。
假設 Alice 想向 Bob 發(fā)送一些 Ether,即 Alice 是發(fā)送者,Bob 是接收者。
Alice 只需要在鏈下(例如通過電子郵件)向 Bob 發(fā)送加密簽名的消息,這類似于寫支票。
Alice 和 Bob 使用簽名來授權交易,這可以通過以太坊上的智能合約實現(xiàn)。Alice 將構建一個簡單的智能合約,讓她傳輸 Ether,但她不會自己調(diào)用函數(shù)來發(fā)起支付,而是讓 Bob 這樣做,從而支付交易費用。
該合同將按以下方式運作:
愛麗絲部署
ReceiverPays
合約,附加足夠的以太幣來支付將要支付的款項。Alice 通過使用她的私鑰簽署消息來授權付款。
Alice 將加密簽名的消息發(fā)送給 Bob。消息不需要保密(稍后解釋),發(fā)送它的機制無關緊要。
Bob 通過向智能合約展示簽名消息來索取他的付款,它會驗證消息的真實性,然后釋放資金。
Alice 不需要與以太坊網(wǎng)絡交互來簽署交易,這個過程是完全離線的。在本教程中,我們將使用web3.js和 MetaMask使用EIP-712中描述的方法在瀏覽器中對消息進行簽名,因為它提供了許多其他安全優(yōu)勢。
/// 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
消息的長度添加到簽名數(shù)據(jù)中。因為我們首先散列,所以消息總是正好 32 字節(jié)長,因此這個長度前綴總是相同的。
對于履行付款的合同,簽署的消息必須包括:
收件人的地址。
要轉(zhuǎn)移的金額。
防止重放攻擊。
重放攻擊是指重復使用已簽名的消息來聲明第二個操作的授權。為了避免重放攻擊,我們使用與以太坊交易本身相同的技術,即所謂的隨機數(shù),即賬戶發(fā)送的交易數(shù)量。智能合約檢查一個隨機數(shù)是否被多次使用。
ReceiverPays
當所有者部署智能合約,支付一些款項,然后銷毀合約時,可能會發(fā)生另一種類型的重放攻擊。后來,他們決定再次部署RecipientPays
智能合約,但新合約不知道之前部署中使用的隨機數(shù),因此攻擊者可以再次使用舊消息。
Alice 可以通過在消息中包含合約地址來防止這種攻擊,并且只接受包含合約地址本身的消息。claimPayment()
您可以在本節(jié)末尾的完整合約函數(shù)的前兩行中找到一個示例。
現(xiàn)在我們已經(jīng)確定了要包含在簽名消息中的信息,我們準備將消息放在一起,散列并簽名。為簡單起見,我們將數(shù)據(jù)連接起來。ethereumjs -abi 庫提供了一個名為soliditySHA3
模仿 Solidity 函數(shù)的行為的函數(shù),該keccak256
函數(shù)應用于使用abi.encodePacked
. ReceiverPays
這是一個為示例創(chuàng)建正確簽名的
JavaScript 函數(shù):
// 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); }
一般來說,ECDSA 簽名由兩個參數(shù)組成, r
和s
。以太坊中的簽名包括名為 的第三個參數(shù)v
,您可以使用它來驗證哪個帳戶的私鑰用于對消息進行簽名,以及交易的發(fā)送者。Solidity 提供了一個內(nèi)置函數(shù)ecrecover,它接受消息以及r
,s
和v
參數(shù),并返回用于簽署消息的地址。
web3.js 生成的簽名是r
, s
和的串聯(lián)v
,所以第一步是將這些參數(shù)分開。您可以在客戶端執(zhí)行此操作,但在智能合約內(nèi)部執(zhí)行此操作意味著您只需要發(fā)送一個簽名參數(shù)而不是三個。將字節(jié)數(shù)組拆分為其組成部分是一團糟,因此我們使用 內(nèi)聯(lián)匯編來完成函數(shù)中的工作splitSignature
(本節(jié)末尾完整合約中的第三個函數(shù))。
智能合約需要確切地知道簽署了哪些參數(shù),因此它必須根據(jù)參數(shù)重新創(chuàng)建消息并將其用于簽名驗證。函數(shù)prefixed
并recoverSigner
在函數(shù)中執(zhí)行此操作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)); } }
Alice 現(xiàn)在構建了一個簡單但完整的支付通道實現(xiàn)。支付渠道使用加密簽名來安全、即時且無交易費用地重復傳輸以太幣。
支付渠道允許參與者在不使用交易的情況下重復轉(zhuǎn)移以太幣。這意味著您可以避免與交易相關的延遲和費用。我們將探索兩方(Alice 和 Bob)之間的簡單單向支付渠道。它包括三個步驟:
Alice 用 Ether 為智能合約提供資金。這“打開”了支付渠道。
愛麗絲簽署消息,指定欠接收者多少以太幣。每次付款都重復此步驟。
Bob“關閉”支付通道,提取他的部分以太幣并將剩余部分發(fā)送回發(fā)送者。
筆記
只有第 1 步和第 3 步需要以太坊交易,第 2 步意味著發(fā)送者通過鏈下方法(例如電子郵件)向接收者發(fā)送加密簽名的消息。這意味著只需要兩筆交易即可支持任意數(shù)量的轉(zhuǎn)賬。
Bob 可以保證收到他的資金,因為智能合約托管了以太幣并兌現(xiàn)了有效的簽名消息。智能合約還強制執(zhí)行超時,因此即使接收者拒絕關閉通道,愛麗絲也可以保證最終收回她的資金。由支付渠道的參與者決定保持開放多長時間。對于短期交易,例如為每分鐘網(wǎng)絡訪問支付網(wǎng)吧費用,支付渠道可能會在有限的時間內(nèi)保持開放。另一方面,對于經(jīng)常性支付,例如支付員工小時工資,支付渠道可能會保持開放數(shù)月或數(shù)年。
為了打開支付通道,Alice 部署了智能合約,附加要托管的 Ether,并指定預期的接收者和通道存在的最長持續(xù)時間。這是 SimplePaymentChannel
本節(jié)末尾的合約中的功能。
愛麗絲通過向鮑勃發(fā)送簽名消息來付款。此步驟完全在以太坊網(wǎng)絡之外執(zhí)行。消息由發(fā)件人加密簽名,然后直接傳輸給收件人。
每條消息都包含以下信息:
智能合約的地址,用于防止跨合約重放攻擊。
到目前為止欠收款人的以太幣總量。
在一系列轉(zhuǎn)賬結(jié)束時,支付通道僅關閉一次。因此,只有一條發(fā)送的消息被兌換。這就是為什么每條消息都指定了累積的 Ether 欠款總額,而不是單個小額支付的金額。收件人自然會選擇兌換最近的消息,因為那是總數(shù)最高的消息。不再需要每條消息的隨機數(shù),因為智能合約只接受一條消息。智能合約的地址仍用于防止用于一個支付渠道的消息被用于不同的渠道。
這是修改后的 JavaScript 代碼,用于對上一節(jié)中的消息進行加密簽名:
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); }
當 Bob 準備好接收他的資金時,是時候通過調(diào)用close
智能合約上的函數(shù)來關閉支付通道了。關閉通道會向接收者支付他們所欠的以太幣并銷毀合約,將剩余的以太幣發(fā)送回愛麗絲。要關閉通道,Bob 需要提供由 Alice 簽名的消息。
智能合約必須驗證消息是否包含來自發(fā)件人的有效簽名。進行此驗證的過程與收件人使用的過程相同。Solidity 的功能isValidSignature
和工作方式與上一節(jié)中的 JavaScript 對應物一樣,后者的功能是從合約中recoverSigner
借用的。ReceiverPays
只有支付渠道接收方可以調(diào)用該close
函數(shù),他們自然會傳遞最新的支付消息,因為該消息攜帶的總欠款總額最高。如果發(fā)件人被允許調(diào)用這個函數(shù),他們可以提供一個較低金額的消息,并欺騙收件人他們欠他們的東西。
該函數(shù)驗證簽名消息與給定參數(shù)匹配。如果一切順利,收件人將收到他們的部分以太幣,而發(fā)件人則通過selfdestruct
. close
您可以在完整的合同中看到該功能。
Bob 可以隨時關閉支付通道,但如果他們不這樣做,Alice 需要一種方法來收回她的托管資金。在合約部署時設置了到期時間。一旦到了那個時間,愛麗絲就可以打電話 claimTimeout
來收回她的資金。claimTimeout
您可以在完整的合同中看到該功能。
調(diào)用此函數(shù)后,Bob 將無法再接收任何 Ether,因此 Bob 在到期之前關閉通道非常重要。
// 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
不使用所有安全檢查。真正的實現(xiàn)應該使用經(jīng)過更嚴格測試的庫,例如該代碼的 openzepplin版本。
與上一節(jié)不同,支付渠道中的消息不會立即兌現(xiàn)。收件人會跟蹤最新消息,并在需要關閉支付渠道時兌現(xiàn)。這意味著收件人對每條消息執(zhí)行自己的驗證至關重要。否則無法保證收款人最終能夠獲得付款。
收件人應使用以下過程驗證每條消息:
驗證消息中的合約地址是否與支付渠道匹配。
驗證新總數(shù)是否為預期金額。
驗證新的總量不超過托管的以太幣數(shù)量。
驗證簽名是否有效并且來自支付渠道發(fā)件人。
我們將使用ethereumjs-util 庫來編寫此驗證。最后一步可以通過多種方式完成,我們使用 JavaScript。以下代碼constructPaymentMessage
從上面的簽名JavaScript 代碼中借用了該函數(shù):
// 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(); }
更多建議: