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