-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: added PaymentChannel smart contract (#493)
Signed-off-by: Logan Nguyen <[email protected]>
- Loading branch information
1 parent
46ee829
commit 9b0cbd4
Showing
3 changed files
with
295 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
// SPDX-License-Identifier: GPL-3.0 | ||
pragma solidity ^0.8.20; | ||
|
||
/// resource: https://docs.soliditylang.org/en/latest/solidity-by-example.html#the-full-contract | ||
|
||
contract PaymentChannel { | ||
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. | ||
|
||
event AccountBalances( uint256 contractBalance, uint256 senderBalance, uint256 recipientBalance); | ||
|
||
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)); | ||
|
||
// emit an event containing balances before closing the channel => easier to keep track of balances and ignore transaction fees | ||
emit AccountBalances(address(this).balance, sender.balance, recipient.balance); | ||
|
||
// closing - distributing crypto logic | ||
recipient.transfer(amount); | ||
sender.transfer(address(this).balance); | ||
|
||
// emit an event containing balances after closing the channel | ||
emit AccountBalances( address(this).balance, sender.balance, recipient.balance); | ||
} | ||
|
||
/// 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); | ||
sender.transfer(address(this).balance); | ||
} | ||
|
||
/// must verify that the signature is a valid signature signed by the sender | ||
function isValidSignature(uint256 amount, bytes memory signature) | ||
internal | ||
view | ||
returns (bool) | ||
{ | ||
// prefix used in Ethereum when signing a message. | ||
bytes32 message = prefixed(keccak256(abi.encodePacked(this, amount))); | ||
|
||
// check that the signature is from the payment sender | ||
return recoverSigner(message, signature) == sender; | ||
} | ||
|
||
/// split bytes signature into r, s, v values | ||
function splitSignature(bytes memory sig) | ||
internal | ||
pure | ||
returns (uint8 v, bytes32 r, bytes32 s) | ||
{ | ||
// a valid signature must have 65 bytes | ||
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); | ||
} | ||
|
||
/// recover the sender's address based on message and signature | ||
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
/*- | ||
* | ||
* Hedera Smart Contracts | ||
* | ||
* Copyright (C) 2023 Hedera Hashgraph, LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
const { expect } = require('chai') | ||
const { ethers } = require('hardhat') | ||
const PaymentChannelHelper = require('./helper') | ||
const { GAS_LIMIT_1_000_000 } = require('../../constants') | ||
|
||
describe.only('@solidityequiv3 PaymentChannel', () => { | ||
const DECIMALS = 8 | ||
const GASLIMIT = 1000000 | ||
const DURATION = 3 // 3 seconds | ||
const OWED_AMOUNT = 100000000 | ||
const INITIAL_FUND = ethers.utils.parseEther('3') | ||
let signers, | ||
senderAddress, | ||
recipientAddress, | ||
paymentSignature, | ||
paymentChannelContract | ||
|
||
before(async () => { | ||
signers = await ethers.getSigners() | ||
senderAddress = await signers[0].getAddress() | ||
recipientAddress = await signers[1].getAddress() | ||
|
||
const paymentChannelContractFactory = await ethers.getContractFactory( | ||
'PaymentChannel' | ||
) | ||
|
||
paymentChannelContract = await paymentChannelContractFactory.deploy( | ||
recipientAddress, | ||
DURATION, | ||
{ | ||
gasLimit: GASLIMIT, | ||
value: INITIAL_FUND, | ||
} | ||
) | ||
|
||
paymentSignature = await PaymentChannelHelper.signPayment( | ||
signers[0], | ||
paymentChannelContract.address, | ||
OWED_AMOUNT | ||
) | ||
}) | ||
|
||
it('Should deployed with correct deployed arguments - open payment channel', async () => { | ||
const contractBalance = await ethers.provider.getBalance( | ||
paymentChannelContract.address | ||
) | ||
|
||
expect(contractBalance).to.eq(INITIAL_FUND) | ||
expect(await paymentChannelContract.expiration()).to.not.eq(0) | ||
expect(await paymentChannelContract.sender()).to.eq(senderAddress) | ||
expect(await paymentChannelContract.recipient()).to.eq(recipientAddress) | ||
}) | ||
|
||
it('Should close the payment channel when recipient execute close method', async () => { | ||
const transaction = await paymentChannelContract | ||
.connect(signers[1]) | ||
.close(OWED_AMOUNT, paymentSignature) | ||
|
||
const receipt = await transaction.wait() | ||
|
||
const [contractBaleBefore, senderBalBefore, recipientBalBefore] = | ||
receipt.events[0].args | ||
|
||
const [contractBaleAfter, senderBalAfter, recipientBalAfter] = | ||
receipt.events[1].args | ||
|
||
// @notice after closing the channel, all the contract balance will be faily distributed to the parties => contractBaleAfter should be 0 | ||
// | ||
// @notice since the OWED_AMOUNT = 100000000, after closing the channel the recipient should receive 100000000 crypto units (i.e. OWED_AMOUNT) | ||
// | ||
// @notice since the OWED_AMOUNT = 100000000 and the INITIAL_FUND (i.e. contractBaleAfter) = 300000000 => | ||
// the left over, 300000000 - 100000000 = 200000000, will be transfered back to the sender (the channel funder) | ||
expect(contractBaleAfter).to.eq(0) | ||
expect(recipientBalAfter - recipientBalBefore).to.eq(OWED_AMOUNT) | ||
expect(senderBalAfter - senderBalBefore).to.eq( | ||
contractBaleBefore - OWED_AMOUNT | ||
) | ||
}) | ||
|
||
it('Shoud extend the expiration of the payment channel when caller is the sender', async () => { | ||
const currentExp = await paymentChannelContract.expiration() | ||
const newExp = Number(currentExp) + DURATION | ||
|
||
// call .extend() by signers[0] (i.e. the sender) | ||
await paymentChannelContract.extend(newExp) | ||
|
||
const updatedExp = await paymentChannelContract.expiration() | ||
|
||
expect(updatedExp).to.eq(newExp) | ||
expect(updatedExp).to.not.eq(currentExp) | ||
}) | ||
|
||
it('Should not extend the expiration of the payment channel when caller is NOT the sender', async () => { | ||
const currentExp = await paymentChannelContract.expiration() | ||
const newExp = Number(currentExp) + DURATION | ||
|
||
// call .extend() by signers[1] (i.e. the recipient) | ||
await paymentChannelContract.connect(signers[1]).extend(newExp) | ||
const updatedExp = await paymentChannelContract.expiration() | ||
|
||
// @notice as the caller is signers[1] who is not the sender => the .extend function will revert | ||
expect(updatedExp).to.eq(currentExp) | ||
expect(updatedExp).to.not.eq(newExp) | ||
}) | ||
|
||
it('Should release back the fund balance stored in the contract to sender when the timeout is reached', async () => { | ||
const currentExp = await paymentChannelContract.expiration() | ||
const sleepTime = Number(currentExp) * 1000 - Date.now() | ||
await new Promise((r) => setTimeout(r, sleepTime)) | ||
await paymentChannelContract.claimTimeout() | ||
const contractBalance = await ethers.provider.getBalance( | ||
paymentChannelContract.address | ||
) | ||
|
||
expect(contractBalance).to.eq(0) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
/*- | ||
* | ||
* Hedera Smart Contracts | ||
* | ||
* Copyright (C) 2023 Hedera Hashgraph, LLC | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
*/ | ||
|
||
const { ethers } = require('hardhat') | ||
|
||
class PaymentChannelHelper { | ||
/** | ||
* @dev constructs a payment message | ||
* | ||
* @param contractAddress used to prevent cross-contract replay attacks | ||
* | ||
* @param amount specifies how much Hbar should be sent | ||
* | ||
* @return Keccak256 hash string | ||
*/ | ||
static constructPaymentMessage(contractAddress, amount) { | ||
return ethers.utils.solidityKeccak256( | ||
['address', 'uint256'], | ||
[contractAddress, amount] | ||
) | ||
} | ||
|
||
/** | ||
* @dev sign the payment message | ||
* | ||
* @param signer signing account | ||
* | ||
* @param contractAddress used to prevent cross-contract replay attacks | ||
* | ||
* @param amount specifies how much Hbar should be sent | ||
* | ||
* @return 65 bytes signature | ||
*/ | ||
static async signPayment(signer, contractAddress, amount) { | ||
const message = this.constructPaymentMessage(contractAddress, amount) | ||
return await signer.signMessage(ethers.utils.arrayify(message)) | ||
} | ||
} | ||
|
||
module.exports = PaymentChannelHelper |