Skip to content

Commit

Permalink
feat: added PaymentChannel smart contract (#493)
Browse files Browse the repository at this point in the history
Signed-off-by: Logan Nguyen <[email protected]>
  • Loading branch information
quiet-node committed Oct 20, 2023
1 parent 46ee829 commit 9b0cbd4
Show file tree
Hide file tree
Showing 3 changed files with 295 additions and 0 deletions.
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));
}
}
137 changes: 137 additions & 0 deletions test/solidity/payment-channel/PaymentChannel.js
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)
})
})
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

0 comments on commit 9b0cbd4

Please sign in to comment.