diff --git a/contracts/solidity/payment-channel/PaymentChannel.sol b/contracts/solidity/payment-channel/PaymentChannel.sol new file mode 100644 index 000000000..da5a661ee --- /dev/null +++ b/contracts/solidity/payment-channel/PaymentChannel.sol @@ -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)); + } +} \ No newline at end of file diff --git a/test/solidity/payment-channel/PaymentChannel.js b/test/solidity/payment-channel/PaymentChannel.js new file mode 100644 index 000000000..4d18743fa --- /dev/null +++ b/test/solidity/payment-channel/PaymentChannel.js @@ -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) + }) +}) diff --git a/test/solidity/payment-channel/helper.js b/test/solidity/payment-channel/helper.js new file mode 100644 index 000000000..f471b96ec --- /dev/null +++ b/test/solidity/payment-channel/helper.js @@ -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