Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added PaymentChannel smart contract (#493) #509

Merged
merged 2 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions contracts/solidity/payment-channel/PaymentChannel.sol
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));
}
}
136 changes: 136 additions & 0 deletions test/solidity/payment-channel/PaymentChannel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*-
*
* 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('@solidityequiv3 PaymentChannel', () => {
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] =
quiet-node marked this conversation as resolved.
Show resolved Hide resolved
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)
})
})
57 changes: 57 additions & 0 deletions test/solidity/payment-channel/helper.js
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