From 4d85f9b35470c504c7b7fcced4e59f8902b6d1b6 Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Thu, 23 Sep 2021 17:31:48 +0000 Subject: [PATCH] Initial commit --- .github/workflows/node.js.yml | 47 +++++ .gitignore | 2 + README.md | 121 +++++++++++ contracts/Migrations.sol | 19 ++ contracts/SupplyChain.sol | 133 ++++++++++++ migrations/1_initial_migration.js | 5 + migrations/2_deploy_contracts.js | 7 + test/TestSupplyChain.sol | 27 +++ test/ast-helper.js | 47 +++++ test/exceptionsHelpers.js | 22 ++ test/supply_chain.test.js | 340 ++++++++++++++++++++++++++++++ truffle-config.js | 9 + 12 files changed, 779 insertions(+) create mode 100644 .github/workflows/node.js.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 contracts/Migrations.sol create mode 100644 contracts/SupplyChain.sol create mode 100644 migrations/1_initial_migration.js create mode 100644 migrations/2_deploy_contracts.js create mode 100644 test/TestSupplyChain.sol create mode 100644 test/ast-helper.js create mode 100644 test/exceptionsHelpers.js create mode 100644 test/supply_chain.test.js create mode 100644 truffle-config.js diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..8bdbf0e --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,47 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Node.js CI + +on: + push: + branches: + - master + - final-updates + pull_request: + branches: + -master + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Install latest truffle + run: npm -g install truffle + + - name: Setup for Consensys Academy + if: ${{ github.repository_owner == 'Consensys-Academy' }} + run: git checkout cea0c82a1a36991ea5943f8e315af30dde903b89 test/supply_chain.test.js + + - name: Setup for student grading + if: ${{ github.repository_owner != 'Consensys-Academy' }} + # get initial commit. this will be the first commit for repos created from template + run: git checkout $(git rev-list --max-parents=0 master) test/supply_chain.test.js + + - name: Run tests + run: truffle test test/supply_chain.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3fbd98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a2ca00 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Supply Chain Exercise + +The Supply Chain directory is a truffle project that contains the required +contract, migration and test files. In this exercise you are going to implement +the SupplyChain.sol contract and write some tests in Solidity. + +Clone this repo to your local machine. + +Follow the comments outlined in SupplyChain.sol (in the contracts directory) to +implement its functions. We have written a set of tests (in javascript) to +determine if your implementation is correct. As an additional challenge, try +writing some Solidity tests in TestSupplyChain.sol. + +To test your implementation run `$ truffle test` from the terminal in the +project directory. There are **23 pending tests** that you must pass to complete +this exercise. + +## Instructions + +Check out the test file to see the tests that define the behavior of the +SupplyChain smart contract. Notice the tests are in `it` blocks and have a +`skip` modifier, which disables the test. To enable the test, remove the +`.skip` modifier. Tests can have two modifiers: `skip` which skips the test, +and `only` which runs only that test. But what if more than one test have the +`only` modifier you may ask? Well only those test marked such will be executed. + +### State variables + + - [ ] should have an owner +
:book: + + The contract should have an owner, of type address that is public. + **hint:** define a public variable `owner` of type address + +
+ + - [ ] should have an skuCount +
:book: + + The contract will keep track of the + [sku](https://en.wikipedia.org/wiki/Stock_keeping_unit)s in our supply + chain. Each item for sale will have a unique sku number. + + **hint**: define a public variable called `skuCounter` of type uint + +
+ +### enum State + +Items can exist in our Supply chain domain in a few states. In Solidity an +`enum` can be used to represent these different states. Remove the `skip` +annotation from the enum tests to proceed. + + - [ ] should define `ForSale` for when an item is put on sale + - [ ] should define `Sold` for when an item has been purchased + - [ ] should define `Shipped` for when an item has been shippd to the buyer + - [ ] should define `Received` for when the shipped item has been received by the buyer + +### Item struct + +How do we describe an item in our supply chain? It is a union of properties: +`name`, `sku`, `price`, `state`, `seller` and `buyer`. We can use a Solidity +`struct` to model this Item. Remove the `skip` annotation from the `Item +struct` tests and proceed. + + - [ ] should have a `name` + - [ ] should have a `sku` + - [ ] should have a `price` + - [ ] should have a `state` + - [ ] should have a `seller` + - [ ] should have a `buyer` + +### SupplyChain Use cases + +**NOTE** Before proceeding, you should un-comment the `fetchItem` function in the contract. This function is necessary to validate the remaining tests. + + - [ ] should add an item with the provided name and price +
:book: + use case: As a seller, I want to add an item for sale. I should +
+ - [ ] should emit a LogForSale event when an item is added +
:book: + use case: Whenever an item is added (placed for sale), the contract should + emit a `LogForSale` event +
+ - [ ] should allow someone to purchase an item and update state accordingly +
:book: + use case: As a buyer, I want to purchase an item that is for sale. +
+ - [ ] should error when not enough value is sent when purchasing an item +
:book: + use case: A buyer will be notified when they do not have enough funds for the purchase +
+ - [ ] should emit LogSold event when and item is purchased +
:book: + use case: Whenever an item is bought (sold), the contract should emit a "LogSold" event +
+ - [ ] should revert when someone that is not the seller tries to call shipItem() +
:book: + use case: As a seller, only I can ship a bought item +
+ - [ ] should allow the seller to mark the item as shipped +
:book: + use case : Whenever an item is shipped, the seller should be able to mark the item as shipped +
+ - [ ] should emit a LogShipped event when an item is shipped +
:book: + use case: Whenever the item is shipped, the contract should emit a "LogShipped" event +
+ - [ ] should allow the buyer to mark the item as received +
:book: + use case: Whenever an item is recieved, the buyer should be able to mark the item as received +
+ - [ ] should revert if an address other than the buyer calls receiveItem() +
:book: + use case: As a buyer, only I can mark the item as received +
+ - [ ] should emit a LogReceived event when an item is received +
:book: + use case: Whenever an item is received, the contract should emit a "LogReceived" event +
diff --git a/contracts/Migrations.sol b/contracts/Migrations.sol new file mode 100644 index 0000000..ef9d595 --- /dev/null +++ b/contracts/Migrations.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.5.16 <0.9.0; + +contract Migrations { + address public owner = msg.sender; + uint public last_completed_migration; + + modifier restricted() { + require( + msg.sender == owner, + "This function is restricted to the contract's owner" + ); + _; + } + + function setCompleted(uint completed) public restricted { + last_completed_migration = completed; + } +} diff --git a/contracts/SupplyChain.sol b/contracts/SupplyChain.sol new file mode 100644 index 0000000..1092fd9 --- /dev/null +++ b/contracts/SupplyChain.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.5.16 <0.9.0; + +contract SupplyChain { + + // + + // + + // + + // + + // + + /* + * Events + */ + + // + + // + + // + + // + + + /* + * Modifiers + */ + + // Create a modifer, `isOwner` that checks if the msg.sender is the owner of the contract + + // = _price); + _; + } + + modifier checkValue(uint _sku) { + //refund them after pay for item (why it is before, _ checks for logic before func) + _; + // uint _price = items[_sku].price; + // uint amountToRefund = msg.value - _price; + // items[_sku].buyer.transfer(amountToRefund); + } + + // For each of the following modifiers, use what you learned about modifiers + // to give them functionality. For example, the forSale modifier should + // require that the item with the given sku has the state ForSale. Note that + // the uninitialized Item.State is 0, which is also the index of the ForSale + // value, so checking that Item.State == ForSale is not sufficient to check + // that an Item is for sale. Hint: What item properties will be non-zero when + // an Item has been added? + + // modifier forSale + // modifier sold(uint _sku) + // modifier shipped(uint _sku) + // modifier received(uint _sku) + + constructor() public { + // 1. Set the owner to the transaction sender + // 2. Initialize the sku count to 0. Question, is this necessary? + } + + function addItem(string memory _name, uint _price) public returns (bool) { + // 1. Create a new item and put in array + // 2. Increment the skuCount by one + // 3. Emit the appropriate event + // 4. return true + + // hint: + // items[skuCount] = Item({ + // name: _name, + // sku: skuCount, + // price: _price, + // state: State.ForSale, + // seller: msg.sender, + // buyer: address(0) + //}); + // + //skuCount = skuCount + 1; + // emit LogForSale(skuCount); + // return true; + } + + // Implement this buyItem function. + // 1. it should be payable in order to receive refunds + // 2. this should transfer money to the seller, + // 3. set the buyer as the person who called this transaction, + // 4. set the state to Sold. + // 5. this function should use 3 modifiers to check + // - if the item is for sale, + // - if the buyer paid enough, + // - check the value after the function is called to make + // sure the buyer is refunded any excess ether sent. + // 6. call the event associated with this function! + function buyItem(uint sku) public {} + + // 1. Add modifiers to check: + // - the item is sold already + // - the person calling this function is the seller. + // 2. Change the state of the item to shipped. + // 3. call the event associated with this function! + function shipItem(uint sku) public {} + + // 1. Add modifiers to check + // - the item is shipped already + // - the person calling this function is the buyer. + // 2. Change the state of the item to received. + // 3. Call the event associated with this function! + function receiveItem(uint sku) public {} + + // Uncomment the following code block. it is needed to run tests + /* function fetchItem(uint _sku) public view */ + /* returns (string memory name, uint sku, uint price, uint state, address seller, address buyer) */ + /* { */ + /* name = items[_sku].name; */ + /* sku = items[_sku].sku; */ + /* price = items[_sku].price; */ + /* state = uint(items[_sku].state); */ + /* seller = items[_sku].seller; */ + /* buyer = items[_sku].buyer; */ + /* return (name, sku, price, state, seller, buyer); */ + /* } */ +} diff --git a/migrations/1_initial_migration.js b/migrations/1_initial_migration.js new file mode 100644 index 0000000..4d5f3f9 --- /dev/null +++ b/migrations/1_initial_migration.js @@ -0,0 +1,5 @@ +var Migrations = artifacts.require("./Migrations.sol"); + +module.exports = function(deployer) { + deployer.deploy(Migrations); +}; diff --git a/migrations/2_deploy_contracts.js b/migrations/2_deploy_contracts.js new file mode 100644 index 0000000..febfaa5 --- /dev/null +++ b/migrations/2_deploy_contracts.js @@ -0,0 +1,7 @@ +//var SimpleBank = artifacts.require("./SimpleBank.sol"); +var SupplyChain = artifacts.require("./SupplyChain.sol"); + +module.exports = function(deployer) { + //deployer.deploy(SimpleBank); + deployer.deploy(SupplyChain); +}; diff --git a/test/TestSupplyChain.sol b/test/TestSupplyChain.sol new file mode 100644 index 0000000..c3008cf --- /dev/null +++ b/test/TestSupplyChain.sol @@ -0,0 +1,27 @@ +pragma solidity ^0.5.0; + +import "truffle/Assert.sol"; +import "truffle/DeployedAddresses.sol"; +import "../contracts/SupplyChain.sol"; + +contract TestSupplyChain { + + // Test for failing conditions in this contracts: + // https://truffleframework.com/tutorials/testing-for-throws-in-solidity-tests + + // buyItem + + // test for failure if user does not send enough funds + // test for purchasing an item that is not for Sale + + // shipItem + + // test for calls that are made by not the seller + // test for trying to ship an item that is not marked Sold + + // receiveItem + + // test calling the function from an address that is not the buyer + // test calling the function on an item not marked Shipped + +} diff --git a/test/ast-helper.js b/test/ast-helper.js new file mode 100644 index 0000000..9ccee79 --- /dev/null +++ b/test/ast-helper.js @@ -0,0 +1,47 @@ +// Novel way to drive behavior of Smart Contract. + +// +const CDTYPE = "ContractDefinition"; +const CNAME = "SupplyChain"; +const contractDefn = ca => + ca.ast.nodes.find(n => n.nodeType === CDTYPE && n.name === CNAME); + +const items = (ca) => { + const item = contractDefn(ca).nodes.find((n) => n.name === "Item"); + if (!item) return null; + + return item + .members + .map((t) => ({ + name: t.name, + nodeType: t.nodeType, + stateVariable: t.stateVariable, + type: t.typeName.name, + mutability: t.typeName.stateMutability, + })); +}; + +const isDefined = members => variableName => { + return members + ? members.find((item) => item.name === variableName) + : null; +}; + +const isPayable = members => variableName => { + if (members === undefined) return false; + const definition = members.find((item) => item.name === variableName); + return definition && definition.mutability === "payable"; +}; + +const isType = members => variableName => type => { + if (members === undefined) return false; + const definition = members.find((item) => item.name === variableName); + return definition && definition.type === type; +}; + +module.exports = { + items, + isDefined, + isPayable, + isType, +}; diff --git a/test/exceptionsHelpers.js b/test/exceptionsHelpers.js new file mode 100644 index 0000000..fde8c5d --- /dev/null +++ b/test/exceptionsHelpers.js @@ -0,0 +1,22 @@ +const errorString = "VM Exception while processing transaction: "; + +async function tryCatch(promise, reason) { + try { + await promise; + throw null; + } + catch (error) { + assert(error, "Expected a VM exception but did not get one"); + assert(error.message.search(errorString + reason) >= 0, "Expected an error containing '" + errorString + reason + "' but got '" + error.message + "' instead"); + } +}; + +module.exports = { + catchRevert : async function(promise) {await tryCatch(promise, "revert" );}, + catchOutOfGas : async function(promise) {await tryCatch(promise, "out of gas" );}, + catchInvalidJump : async function(promise) {await tryCatch(promise, "invalid JUMP" );}, + catchInvalidOpcode : async function(promise) {await tryCatch(promise, "invalid opcode" );}, + catchStackOverflow : async function(promise) {await tryCatch(promise, "stack overflow" );}, + catchStackUnderflow : async function(promise) {await tryCatch(promise, "stack underflow" );}, + catchStaticStateChange : async function(promise) {await tryCatch(promise, "static state change");}, +}; diff --git a/test/supply_chain.test.js b/test/supply_chain.test.js new file mode 100644 index 0000000..208cdcb --- /dev/null +++ b/test/supply_chain.test.js @@ -0,0 +1,340 @@ +let BN = web3.utils.BN; +let SupplyChain = artifacts.require("SupplyChain"); +let { catchRevert } = require("./exceptionsHelpers.js"); +const { items: ItemStruct, isDefined, isPayable, isType } = require("./ast-helper"); + +contract("SupplyChain", function (accounts) { + const [_owner, alice, bob] = accounts; + const emptyAddress = "0x0000000000000000000000000000000000000000"; + + const price = "1000"; + const excessAmount = "2000"; + const name = "book"; + + let instance; + + beforeEach(async () => { + instance = await SupplyChain.new(); + }); + + describe("Variables", () => { + it("should have an owner", async () => { + assert.equal(typeof instance.owner, 'function', "the contract has no owner"); + }); + + it("should have an skuCount", async () => { + assert.equal(typeof instance.skuCount, 'function', "the contract has no skuCount"); + }); + + describe("enum State", () => { + let enumState; + before(() => { + enumState = SupplyChain.enums.State; + assert( + enumState, + "The contract should define an Enum called State" + ); + }); + + it("should define `ForSale`", () => { + assert( + enumState.hasOwnProperty('ForSale'), + "The enum does not have a `ForSale` value" + ); + }); + + it("should define `Sold`", () => { + assert( + enumState.hasOwnProperty('Sold'), + "The enum does not have a `Sold` value" + ); + }); + + it("should define `Shipped`", () => { + assert( + enumState.hasOwnProperty('Shipped'), + "The enum does not have a `Shipped` value" + ); + }); + + it("should define `Received`", () => { + assert( + enumState.hasOwnProperty('Received'), + "The enum does not have a `Received` value" + ); + }); + }) + + describe("Item struct", () => { + let subjectStruct; + + before(() => { + subjectStruct = ItemStruct(SupplyChain); + assert( + subjectStruct !== null, + "The contract should define an `Item Struct`" + ); + }); + + it("should have a `name`", () => { + assert( + isDefined(subjectStruct)("name"), + "Struct Item should have a `name` member" + ); + assert( + isType(subjectStruct)("name")("string"), + "`name` should be of type `string`" + ); + }); + + it("should have a `sku`", () => { + assert( + isDefined(subjectStruct)("sku"), + "Struct Item should have a `sku` member" + ); + assert( + isType(subjectStruct)("sku")("uint"), + "`sku` should be of type `uint`" + ); + }); + + it("should have a `price`", () => { + assert( + isDefined(subjectStruct)("price"), + "Struct Item should have a `price` member" + ); + assert( + isType(subjectStruct)("price")("uint"), + "`price` should be of type `uint`" + ); + }); + + it("should have a `state`", () => { + assert( + isDefined(subjectStruct)("state"), + "Struct Item should have a `state` member" + ); + assert( + isType(subjectStruct)("state")("State"), + "`state` should be of type `State`" + ); + }); + + it("should have a `seller`", () => { + assert( + isDefined(subjectStruct)("seller"), + "Struct Item should have a `seller` member" + ); + assert( + isType(subjectStruct)("seller")("address"), + "`seller` should be of type `address`" + ); + assert( + isPayable(subjectStruct)("seller"), + "`seller` should be payable" + ); + }); + + it("should have a `buyer`", () => { + assert( + isDefined(subjectStruct)("buyer"), + "Struct Item should have a `buyer` member" + ); + assert( + isType(subjectStruct)("buyer")("address"), + "`buyer` should be of type `address`" + ); + assert( + isPayable(subjectStruct)("buyer"), + "`buyer` should be payable" + ); + }); + }); + }); + + describe("Use cases", () => { + it("should add an item with the provided name and price", async () => { + await instance.addItem(name, price, { from: alice }); + + const result = await instance.fetchItem.call(0); + + assert.equal( + result[0], + name, + "the name of the last added item does not match the expected value", + ); + assert.equal( + result[2].toString(10), + price, + "the price of the last added item does not match the expected value", + ); + assert.equal( + result[3].toString(10), + SupplyChain.State.ForSale, + 'the state of the item should be "For Sale"', + ); + assert.equal( + result[4], + alice, + "the address adding the item should be listed as the seller", + ); + assert.equal( + result[5], + emptyAddress, + "the buyer address should be set to 0 when an item is added", + ); + }); + + it("should emit a LogForSale event when an item is added", async () => { + let eventEmitted = false; + const tx = await instance.addItem(name, price, { from: alice }); + + if (tx.logs[0].event == "LogForSale") { + eventEmitted = true; + } + + assert.equal( + eventEmitted, + true, + "adding an item should emit a For Sale event", + ); + }); + + it("should allow someone to purchase an item and update state accordingly", async () => { + await instance.addItem(name, price, { from: alice }); + var aliceBalanceBefore = await web3.eth.getBalance(alice); + var bobBalanceBefore = await web3.eth.getBalance(bob); + + await instance.buyItem(0, { from: bob, value: excessAmount }); + + var aliceBalanceAfter = await web3.eth.getBalance(alice); + var bobBalanceAfter = await web3.eth.getBalance(bob); + + const result = await instance.fetchItem.call(0); + + assert.equal( + result[3].toString(10), + SupplyChain.State.Sold, + 'the state of the item should be "Sold"', + ); + + assert.equal( + result[5], + bob, + "the buyer address should be set bob when he purchases an item", + ); + + assert.equal( + new BN(aliceBalanceAfter).toString(), + new BN(aliceBalanceBefore).add(new BN(price)).toString(), + "alice's balance should be increased by the price of the item", + ); + + assert.isBelow( + Number(bobBalanceAfter), + Number(new BN(bobBalanceBefore).sub(new BN(price))), + "bob's balance should be reduced by more than the price of the item (including gas costs)", + ); + }); + + it("should error when not enough value is sent when purchasing an item", async () => { + await instance.addItem(name, price, { from: alice }); + await catchRevert(instance.buyItem(0, { from: bob, value: 1 })); + }); + + it("should emit LogSold event when and item is purchased", async () => { + var eventEmitted = false; + + await instance.addItem(name, price, { from: alice }); + const tx = await instance.buyItem(0, { from: bob, value: excessAmount }); + + if (tx.logs[0].event == "LogSold") { + eventEmitted = true; + } + + assert.equal(eventEmitted, true, "adding an item should emit a Sold event"); + }); + + it("should revert when someone that is not the seller tries to call shipItem()", async () => { + await instance.addItem(name, price, { from: alice }); + await instance.buyItem(0, { from: bob, value: price }); + await catchRevert(instance.shipItem(0, { from: bob })); + }); + + it("should allow the seller to mark the item as shipped", async () => { + await instance.addItem(name, price, { from: alice }); + await instance.buyItem(0, { from: bob, value: excessAmount }); + await instance.shipItem(0, { from: alice }); + + const result = await instance.fetchItem.call(0); + + assert.equal( + result[3].toString(10), + SupplyChain.State.Shipped, + 'the state of the item should be "Shipped"', + ); + }); + + it("should emit a LogShipped event when an item is shipped", async () => { + var eventEmitted = false; + + await instance.addItem(name, price, { from: alice }); + await instance.buyItem(0, { from: bob, value: excessAmount }); + const tx = await instance.shipItem(0, { from: alice }); + + if (tx.logs[0].event == "LogShipped") { + eventEmitted = true; + } + + assert.equal( + eventEmitted, + true, + "adding an item should emit a Shipped event", + ); + }); + + it("should allow the buyer to mark the item as received", async () => { + await instance.addItem(name, price, { from: alice }); + await instance.buyItem(0, { from: bob, value: excessAmount }); + await instance.shipItem(0, { from: alice }); + await instance.receiveItem(0, { from: bob }); + + const result = await instance.fetchItem.call(0); + + assert.equal( + result[3].toString(10), + SupplyChain.State.Received, + 'the state of the item should be "Received"', + ); + }); + + it("should revert if an address other than the buyer calls receiveItem()", async () => { + await instance.addItem(name, price, { from: alice }); + await instance.buyItem(0, { from: bob, value: excessAmount }); + await instance.shipItem(0, { from: alice }); + + await catchRevert(instance.receiveItem(0, { from: alice })); + }); + + it("should emit a LogReceived event when an item is received", async () => { + var eventEmitted = false; + + await instance.addItem(name, price, { from: alice }); + await instance.buyItem(0, { from: bob, value: excessAmount }); + await instance.shipItem(0, { from: alice }); + const tx = await instance.receiveItem(0, { from: bob }); + + if (tx.logs[0].event == "LogReceived") { + eventEmitted = true; + } + + assert.equal( + eventEmitted, + true, + "adding an item should emit a Shipped event", + ); + }); + + }); + +}); diff --git a/truffle-config.js b/truffle-config.js new file mode 100644 index 0000000..e5af9e5 --- /dev/null +++ b/truffle-config.js @@ -0,0 +1,9 @@ +module.exports = { + networks: { + local: { + host: "localhost", + port: 8545, + network_id: "*" // Match any network id + } + } +};