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

Challenge 5 migration to extension #250

Open
wants to merge 6 commits into
base: challenge-5-state-channels--extension
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
293 changes: 293 additions & 0 deletions README.md

Large diffs are not rendered by default.

281 changes: 281 additions & 0 deletions extension/README.md.args.mjs

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions extension/packages/hardhat/contracts/Streamer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";

contract Streamer is Ownable {
event Opened(address, uint256);
event Challenged(address);
event Withdrawn(address, uint256);
event Closed(address);

mapping(address => uint256) balances;
mapping(address => uint256) canCloseAt;

constructor() Ownable(msg.sender) {}

function fundChannel() public payable {
/*
Checkpoint 2: fund a channel

Complete this function so that it:
- reverts if msg.sender already has a running channel (ie, if balances[msg.sender] != 0)
- updates the balances mapping with the eth received in the function call
- emits an Opened event
*/
}

function timeLeft(address channel) public view returns (uint256) {
if (canCloseAt[channel] == 0 || canCloseAt[channel] < block.timestamp) {
return 0;
}

return canCloseAt[channel] - block.timestamp;
}

function withdrawEarnings(Voucher calldata voucher) public {
// like the off-chain code, signatures are applied to the hash of the data
// instead of the raw data itself
bytes32 hashed = keccak256(abi.encode(voucher.updatedBalance));

// The prefix string here is part of a convention used in ethereum for signing
// and verification of off-chain messages. The trailing 32 refers to the 32 byte
// length of the attached hash message.
//
// There are seemingly extra steps here compared to what was done in the off-chain
// `reimburseService` and `processVoucher`. Note that those ethers signing and verification
// functions do the same under the hood.
//
// see https://blog.ricmoo.com/verifying-messages-in-solidity-50a94f82b2ca
bytes memory prefixed = abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
hashed
);
bytes32 prefixedHashed = keccak256(prefixed);

/*
Checkpoint 4: Recover earnings

The service provider would like to cash out their hard earned ether.
- use ecrecover on prefixedHashed and the supplied signature
- require that the recovered signer has a running channel with balances[signer] > v.updatedBalance
- calculate the payment when reducing balances[signer] to v.updatedBalance
- adjust the channel balance, and pay the Guru(Contract owner). Get the owner address with the `owner()` function.
- emit the Withdrawn event
*/
}

/*
Checkpoint 5a: Challenge the channel

Create a public challengeChannel() function that:
- checks that msg.sender has an open channel
- updates canCloseAt[msg.sender] to some future time
- emits a Challenged event
*/

/*
Checkpoint 5b: Close the channel

Create a public defundChannel() function that:
- checks that msg.sender has a closing channel
- checks that the current time is later than the closing time
- sends the channel's remaining funds to msg.sender, and sets the balance to 0
- emits the Closed event
*/

struct Voucher {
uint256 updatedBalance;
Signature sig;
}
struct Signature {
bytes32 r;
bytes32 s;
uint8 v;
}
}
54 changes: 54 additions & 0 deletions extension/packages/hardhat/deploy/00_deploy_streamer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { DeployFunction } from "hardhat-deploy/types";
// import { Streamer } from "../typechain-types";

/**
* Deploys a contract named "Streamer" using the deployer account and
* constructor arguments set to the deployer address
*
* @param hre HardhatRuntimeEnvironment object.
*/
const deployStreamer: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
/*
On localhost, the deployer account is the one that comes with Hardhat, which is already funded.

When deploying to live networks (e.g `yarn deploy --network sepolia`), the deployer account
should have sufficient balance to pay for the gas fees for contract creation.

You can generate a random account with `yarn generate` which will fill DEPLOYER_PRIVATE_KEY
with a random private key in the .env file (then used on hardhat.config.ts)
You can run the `yarn account` command to check your balance in every network.
*/

const { deployer } = await hre.getNamedAccounts();
const { deploy } = hre.deployments;

await deploy("Streamer", {
from: deployer,
// Contract constructor arguments
args: [],
log: true,
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
// automatically mining the contract deployment transaction. There is no effect on live networks.
autoMine: true,
});

// // *Checkpoint 1*
// // Get the deployed contract
// const streamer: Streamer = await hre.ethers.getContract("Streamer", deployer);

// // Transfer ownership to your front end address
// console.log("\n 🤹 Sending ownership to frontend address...\n");
// const ownerTx = await streamer.transferOwnership("** YOUR FRONTEND ADDRESS **");
// console.log("\n confirming...\n");
// const ownershipResult = await ownerTx.wait();
// if (ownershipResult) {
// console.log(" ✅ ownership transferred successfully!\n");
// }
};

export default deployStreamer;

// Tags are useful if you have multiple deploy files and only want to run one of them.
// e.g. yarn deploy --tags Streamer
deployStreamer.tags = ["Streamer"];
200 changes: 200 additions & 0 deletions extension/packages/hardhat/test/Challenge5.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import hre from "hardhat";

import { expect, assert } from "chai";
import { network } from "hardhat";
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { Streamer } from "../typechain-types";

const { ethers } = hre;

describe(" 🕞 Statechannel Challenge: The Guru's Offering 👑", function () {
this.timeout(120000);
let streamerContract: Streamer;

/**
* asserts that the steamerContract's balance is equal to b,
* denominated in ether
*
* @param {string} b
*/
async function assertBalance(b: string) {
const streamerContractAddress = await streamerContract.getAddress();
const balance = await network.provider.send("eth_getBalance", [streamerContractAddress]);
console.log("\t", "💵 Balance", ethers.formatEther(balance));
expect(await network.provider.send("eth_getBalance", [streamerContractAddress])).to.equal(ethers.parseEther(b));
return;
}

/**
* Creates a redeemable voucher for the given balance
* in the name of `signer`
*
* @param {bigint} updatedBalance
* @param {HardhatEthersSigner} signer
* @returns
*/
async function createVoucher(updatedBalance: bigint, signer: HardhatEthersSigner) {
const packed = ethers.solidityPacked(["uint256"], [updatedBalance]);
const hashed = ethers.keccak256(packed);
const arrayified = ethers.getBytes(hashed);

const carolSig = await signer.signMessage(arrayified);

const voucher = {
updatedBalance,
// TODO: change when viem will implement splitSignature
sig: ethers.Signature.from(carolSig),
};
return voucher;
}

describe("Streamer.sol", function () {
const contractAddress = process.env.CONTRACT_ADDRESS;
let contractArtifact: string;
if (contractAddress) {
contractArtifact = `contracts/download-${contractAddress}.sol:Streamer`;
} else {
contractArtifact = "contracts/Streamer.sol:Streamer";
}

it("Should deploy the contract", async function () {
const streamerFct = await ethers.getContractFactory(contractArtifact);
streamerContract = (await streamerFct.deploy()) as Streamer;
const streamerContractAddress = await streamerContract.getAddress();
console.log("\t", "🛫 Contract deployed", streamerContractAddress);
});

it("Should allow channel funding & emit Opened event", async function () {
console.log("\t", "💸 Funding first channel...");
const fundingTx = await streamerContract.fundChannel({
value: ethers.parseEther("1"),
});
console.log("\t", "⏫ Checking emit");
await expect(fundingTx).to.emit(streamerContract, "Opened");
});

it("Should refuse multiple funding from single user", async function () {
console.log("\t", "🔃 Attempting to fund the channel again...");
await expect(
streamerContract.fundChannel({
value: ethers.parseEther("1"), // first funded channel
}),
).to.be.reverted;
});

it("Should allow multiple client channels", async function () {
const [, alice, bob] = await ethers.getSigners();

console.log("\t", "💸 Funding a second channel...");
await expect(
streamerContract.connect(alice).fundChannel({
value: ethers.parseEther("1"), // second funded channel
}),
).to.emit(streamerContract, "Opened");

console.log("\t", "💸 Funding a third channel...");
await expect(
streamerContract.connect(bob).fundChannel({
value: ethers.parseEther("1"), // third funded channel
}),
).to.emit(streamerContract, "Opened");

console.log("\t", "💵 Expecting contract balance to equal 3...");
await assertBalance("3"); // running total
});

it("Should allow legitimate withdrawals", async function () {
const [deployer, alice] = await ethers.getSigners();

const deployerBalance = await network.provider.send("eth_getBalance", [deployer.address]);
const updatedBalance = ethers.parseEther("0.5"); // cut channel balance from 1 -> 0.5
console.log("\t", "📩 Creating voucher...");
const voucher = await createVoucher(updatedBalance, alice);

console.log("\t", "🔼 Expecting to withdraw funds and emit Withdrawn...");
await expect(streamerContract.withdrawEarnings(voucher)).to.emit(streamerContract, "Withdrawn");
console.log("\t", "💵 Expecting contract balance to equal 2.5...");
await assertBalance("2.5"); // 3 - 0.5 = 2.5
const finalDeployerBalance = await network.provider.send("eth_getBalance", [deployer.address]);
await expect(finalDeployerBalance).to.be.approximately(
BigInt(deployerBalance) + updatedBalance,
// gas for withdrawEarnings
ethers.parseEther("0.01"),
);
});

it("Should refuse redundant withdrawals", async function () {
const [, alice] = await ethers.getSigners();

const updatedBalance = ethers.parseEther("0.5"); // equal to the current balance, should fail
console.log("\t", "📩 Creating voucher...");
const voucher = await createVoucher(updatedBalance, alice);

console.log("\t", "🛑 Attempting a redundant withdraw...");
await expect(streamerContract.withdrawEarnings(voucher)).to.be.reverted;
console.log("\t", "💵 Expecting contract balance to equal 2.5...");
await assertBalance("2.5"); // contract total unchanged because withdrawal fails
});

it("Should refuse illegitimate withdrawals", async function () {
const [, , , carol] = await ethers.getSigners(); // carol has no open channel

const updatedBalance = ethers.parseEther("0.5");
console.log("\t", "📩 Creating voucher...");
const voucher = await createVoucher(updatedBalance, carol);

console.log("\t", "🛑 Attempting an illegitimate withdraw...");
await expect(streamerContract.withdrawEarnings(voucher)).to.be.reverted;
console.log("\t", "💵 Expecting contract balance to equal 2.5...");
await assertBalance("2.5"); // contract total unchanged because carol has no channel
});

it("Should refuse defunding when no challenge has been registered", async function () {
const [, , bob] = await ethers.getSigners();

console.log("\t", "🛑 Attempting illegitimate defundChannel...");
await expect(streamerContract.connect(bob).defundChannel()).to.be.reverted;
console.log("\t", "💵 Expecting contract balance to equal 2.5...");
await assertBalance("2.5"); // contract total unchanged because defund fails
});

it("Should emit a Challenged event", async function () {
const [, , bob] = await ethers.getSigners();
await expect(streamerContract.connect(bob).challengeChannel()).to.emit(streamerContract, "Challenged");
console.log("\t", "💵 Expecting contract balance to equal 2.5...");
await assertBalance("2.5"); // contract total unchanged because challenge does not move funds
});

it("Should refuse defunding during the challenge period", async function () {
const [, , bob] = await ethers.getSigners();

console.log("\t", "🛑 Attempting illegitimate defundChannel...");
await expect(streamerContract.connect(bob).defundChannel()).to.be.reverted;
console.log("\t", "💵 Expecting contract balance to equal 2.5...");
await assertBalance("2.5"); // contract total unchanged becaues defund fails
});

it("Should allow defunding after the challenge period", async function () {
const [, , bob] = await ethers.getSigners();

const initBobBalance = await network.provider.send("eth_getBalance", [bob.address]);
console.log("\t", "💰 Initial user balance:", ethers.formatEther(initBobBalance));
console.log("\t", "🕐 Increasing time...");
network.provider.send("evm_increaseTime", [3600]); // fast-forward one hour
network.provider.send("evm_mine");

console.log("\t", "💲 Attempting a legitimate defundChannel...");
await expect(streamerContract.connect(bob).defundChannel()).to.emit(streamerContract, "Closed");
console.log("\t", "💵 Expecting contract balance to equal 1.5...");
await assertBalance("1.5"); // 2.5-1 = 1.5 (bob defunded his unused channel)

const finalBobBalance = await network.provider.send("eth_getBalance", [bob.address]);

console.log("\t", "💰 User's final balance:", ethers.formatEther(finalBobBalance));
// check that bob's channel balance returned to bob's address
const difference = BigInt(finalBobBalance) - BigInt(initBobBalance);
console.log("\t", "💵 Checking that final balance went up by ~1 eth. Increase", ethers.formatEther(difference));
assert(difference > ethers.parseEther("0.99"));
});
});
});
4 changes: 4 additions & 0 deletions extension/packages/nextjs/app/layout.tsx.args.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const metadata = {
title: "Challenge #5 | SpeedRunEthereum",
description: "Built with 🏗 Scaffold-ETH 2",
};
Loading