diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04422a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# IDE +.DS_Store \ No newline at end of file diff --git a/mist-invoice-contracts/.gitignore b/mist-invoice-contracts/.gitignore index 783d4d8..9f79abf 100644 --- a/mist-invoice-contracts/.gitignore +++ b/mist-invoice-contracts/.gitignore @@ -2,4 +2,6 @@ node_modules cache out -broadcast \ No newline at end of file +broadcast +.yarn +src/types diff --git a/mist-invoice-contracts/script/createMistInvoice.s.sol b/mist-invoice-contracts/script/createMistInvoice.s.sol index 50f35e8..7b067b5 100644 --- a/mist-invoice-contracts/script/createMistInvoice.s.sol +++ b/mist-invoice-contracts/script/createMistInvoice.s.sol @@ -10,9 +10,7 @@ contract InvoiceCreationScript is Script { uint256 merkleRoot; uint256 providerHash; uint256 clientHash; - bytes encData; - bytes encClientKey; - bytes encProviderKey; + bytes[] encData; } function getSepoliaDummyData() public view returns (bytes memory) { @@ -85,15 +83,15 @@ contract InvoiceCreationScript is Script { address mistWrapperDeployment = vm.envAddress("MIST_INVOICE_ESCROW_WRAPPER"); MistInvoiceEscrowWrapper mistWrapper = MistInvoiceEscrowWrapper(mistWrapperDeployment); + bytes[] memory encDataArray = new bytes[](1); + encDataArray[0] = abi.encodePacked("dummyEncData"); // Dummy data MistSecret memory _mistData = MistSecret({ merkleRoot: uint256(keccak256(abi.encodePacked("dummyMerkleRoot"))), clientHash: uint256(keccak256(abi.encodePacked("dummyClientRandom"))), providerHash: uint256(keccak256(abi.encodePacked("dummyProviderRandom"))), - encData: abi.encodePacked("dummyEncData"), - encClientKey: abi.encodePacked("dummyClientKey"), - encProviderKey: abi.encodePacked("dummyProviderKey") + encData: encDataArray }); uint256[] memory _amounts = new uint256[](1); @@ -105,9 +103,7 @@ contract InvoiceCreationScript is Script { merkleRoot: _mistData.merkleRoot, clientHash: _mistData.clientHash, providerHash: _mistData.providerHash, - encData: _mistData.encData, - encClientKey: _mistData.encClientKey, - encProviderKey: _mistData.encProviderKey + encData: _mistData.encData }); address invoiceAddress = mistWrapper.createInvoice(mistDataForWrapper, _amounts, _data, _type); diff --git a/mist-invoice-contracts/src/MistInvoiceEscrowWrapper.sol b/mist-invoice-contracts/src/MistInvoiceEscrowWrapper.sol index ccd0479..611aa23 100644 --- a/mist-invoice-contracts/src/MistInvoiceEscrowWrapper.sol +++ b/mist-invoice-contracts/src/MistInvoiceEscrowWrapper.sol @@ -11,24 +11,25 @@ import "./libraries/PoolStructs.sol"; contract MistInvoiceEscrowWrapper is Verifier { uint256 constant SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617; - uint256 public constant CLIENT_SIGNAL = uint256(keccak256("client")) / SNARK_SCALAR_FIELD; - uint256 public constant PROVIDER_SIGNAL = uint256(keccak256("provider")) / SNARK_SCALAR_FIELD; + uint256 public constant CLIENT_SIGNAL = uint256(keccak256("client")) % SNARK_SCALAR_FIELD; + uint256 public constant PROVIDER_SIGNAL = uint256(keccak256("provider")) % SNARK_SCALAR_FIELD; address public immutable INVOICE_FACTORY; IMISTPool public mistPool; mapping(address => MistSecret) mistSecrets; - enum ROLE {CLIENT, PROVIDER} + enum ROLE { + CLIENT, + PROVIDER + } // TODO how many bytes? struct MistSecret { uint256 merkleRoot; // contains client and provider addresses uint256 providerHash; uint256 clientHash; - bytes encData; // symmetric key for encrypted invoice _details, client address and random, provider address and random - bytes encClientKey; - bytes encProviderKey; + bytes[] encData; // abi.encode(tuple(string encryptedData, string encryptedSenderKey, string encryptedReceiverKey)) } constructor(address _invoiceFactory, address _mistPool) { @@ -62,31 +63,42 @@ contract MistInvoiceEscrowWrapper is Verifier { } // TODO is reentrency guard needed? - function privateRelease(address _invoiceAddr, uint256 _milestone, bytes calldata _encNote, uint256[4] calldata _digest, Proof calldata _proof) external { + function privateRelease( + address _invoiceAddr, + uint256 _milestone, + uint256[4] calldata _digest, + Proof calldata _proof + ) external { require(_invoiceAddr != address(0), "invalid invoice required"); - require(_encNote.length > 0, "encrypted note required"); // TODO verify proof - require(verify(_proof, mistSecrets[_invoiceAddr].merkleRoot, _digest, CLIENT_SIGNAL), "invalid proof"); + require( + verify(_proof, mistSecrets[_invoiceAddr].merkleRoot, _digest, CLIENT_SIGNAL), + "invalid proof" + ); // TODO call release on _invoiceAddr ISmartInvoiceEscrow escrow = ISmartInvoiceEscrow(_invoiceAddr); IERC20 token = IERC20(escrow.token()); - uint256 preBalance = token.balanceOf(address(this)); + // uint256 preBalance = token.balanceOf(address(this)); + uint256 currentMilestone = escrow.milestone(); escrow.release(_milestone); - uint256 postBalance = token.balanceOf(address(this)); + // uint256 postBalance = token.balanceOf(address(this)); // TODO save amount to provider data - uint256 providerOwed = postBalance - preBalance; + // uint256 providerOwed = postBalance - preBalance; // TODO deposit to MIST pool - PreCommitment[] memory preCommitments = new PreCommitment[](1); - preCommitments[0] = PreCommitment({ - receiverHash: mistSecrets[_invoiceAddr].providerHash, - encryptedNote: _encNote, - tokenData: TokenData({ - standard: TokenStandard.ERC20, - token: address(token), - identifier: 0, - amount: providerOwed - }) - }); + uint256 length = _milestone - currentMilestone + 1; + PreCommitment[] memory preCommitments = new PreCommitment[](length); + for (uint256 i = 0; i < length; i++) { + preCommitments[i] = PreCommitment({ + receiverHash: mistSecrets[_invoiceAddr].providerHash, + encryptedNote: mistSecrets[_invoiceAddr].encData[i + currentMilestone], + tokenData: TokenData({ + standard: TokenStandard.ERC20, + token: address(token), + identifier: 0, + amount: escrow.amounts()[i + currentMilestone] + }) + }); + } DepositData memory depositData = DepositData({ nonce: mistPool.getNonce(address(this)), sender: address(this), @@ -95,14 +107,29 @@ contract MistInvoiceEscrowWrapper is Verifier { mistPool.deposit(depositData, bytes("")); } - function privateDispute(address _invoiceAddr, bytes32 _details, uint256[4] calldata _digest, ROLE _role, Proof calldata _proof) external { + function privateDispute( + address _invoiceAddr, + bytes32 _details, + uint256[4] calldata _digest, + ROLE _role, + Proof calldata _proof + ) external { require(_invoiceAddr != address(0), "valid invoice required"); - require(_role == ROLE.CLIENT || _role == ROLE.PROVIDER, "valid role required"); + require( + _role == ROLE.CLIENT || _role == ROLE.PROVIDER, + "valid role required" + ); // TODO verify proof if (_role == ROLE.CLIENT) { - require(verify(_proof, mistSecrets[_invoiceAddr].clientHash, _digest, CLIENT_SIGNAL), "invalid proof"); + require( + verify(_proof, mistSecrets[_invoiceAddr].merkleRoot, _digest, CLIENT_SIGNAL), + "invalid proof" + ); } else { - require(verify(_proof, mistSecrets[_invoiceAddr].providerHash, _digest, PROVIDER_SIGNAL), "invalid proof"); + require( + verify(_proof, mistSecrets[_invoiceAddr].merkleRoot, _digest, PROVIDER_SIGNAL), + "invalid proof" + ); } // TODO call lock on _invoiceAddr ISmartInvoiceEscrow escrow = ISmartInvoiceEscrow(_invoiceAddr); @@ -110,22 +137,36 @@ contract MistInvoiceEscrowWrapper is Verifier { } // TODO other resolve arguments needed - function resolve(address _invoiceAddr, uint256 _clientAward, uint256 _providerAward, bytes32 _details, bytes[2] calldata _encNotes) external { + function resolve( + address _invoiceAddr, + uint256 _clientAward, + uint256 _providerAward, + bytes32 _details, + bytes[2] calldata _encNotes + ) external { require(_invoiceAddr != address(0), "valid invoice required"); - require(_encNotes[0].length > 0 && _encNotes[1].length > 0, "encrypted notes required"); + require( + _encNotes[0].length > 0 && _encNotes[1].length > 0, + "encrypted notes required" + ); // TODO calculate client, provider, arb splits; update mappings ISmartInvoiceEscrow escrow = ISmartInvoiceEscrow(_invoiceAddr); IERC20 token = IERC20(escrow.token()); uint256 preBalance = token.balanceOf(address(this)); // TODO call invoice resolve _invoiceAddr.delegatecall( - abi.encodeWithSelector(ISmartInvoiceEscrow.resolve.selector, _clientAward, _providerAward, _details) + abi.encodeWithSelector( + ISmartInvoiceEscrow.resolve.selector, + _clientAward, + _providerAward, + _details + ) ); uint256 postBalance = token.balanceOf(address(this)); // TODO save amount to provider data // uint256 totalOwed = postBalance - preBalance; - uint256 forClient = _clientAward / (_clientAward + _providerAward) * (postBalance - preBalance); - uint256 forProvider = _providerAward / (_clientAward + _providerAward) * (postBalance - preBalance); + uint256 forClient = (_clientAward / (_clientAward + _providerAward)) * (postBalance - preBalance); + uint256 forProvider = (_providerAward / (_clientAward + _providerAward)) * (postBalance - preBalance); // TODO deposit to MIST pool for client + provider PreCommitment[] memory preCommitments = new PreCommitment[](2); preCommitments[0] = PreCommitment({ @@ -157,12 +198,20 @@ contract MistInvoiceEscrowWrapper is Verifier { } // Only works for primary token, withdrawTokens is not implemented for hackathon - function privateWithdraw(address _invoiceAddr, uint256[4] calldata _digest, Proof calldata _proof, bytes calldata _encNote) external { + function privateWithdraw( + address _invoiceAddr, + uint256[4] calldata _digest, + Proof calldata _proof, + bytes calldata _encNote + ) external { require(_invoiceAddr != address(0), "valid invoice required"); require(_encNote.length > 0, "encrypted note required"); // TODO verify withdraw enabled // TODO verify proof of client - require(verify(_proof, mistSecrets[_invoiceAddr].clientHash, _digest, CLIENT_SIGNAL), "invalid proof"); + require( + verify(_proof, mistSecrets[_invoiceAddr].merkleRoot, _digest, CLIENT_SIGNAL), + "invalid proof" + ); // TODO call withdraw on invoice ISmartInvoiceEscrow escrow = ISmartInvoiceEscrow(_invoiceAddr); @@ -176,7 +225,7 @@ contract MistInvoiceEscrowWrapper is Verifier { // mistPool.deposit(owed) PreCommitment[] memory preCommitments = new PreCommitment[](1); preCommitments[0] = PreCommitment({ - receiverHash: mistSecrets[_invoiceAddr].providerHash, + receiverHash: mistSecrets[_invoiceAddr].clientHash, encryptedNote: _encNote, tokenData: TokenData({ standard: TokenStandard.ERC20, @@ -192,4 +241,4 @@ contract MistInvoiceEscrowWrapper is Verifier { }); mistPool.deposit(depositData, bytes("")); } -} +} \ No newline at end of file diff --git a/mist-invoice-dapp/.eslintrc.json b/mist-invoice-dapp/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/mist-invoice-dapp/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/mist-invoice-dapp/.gitignore b/mist-invoice-dapp/.gitignore new file mode 100644 index 0000000..e45f92a --- /dev/null +++ b/mist-invoice-dapp/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ + +# production +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# yarn +.yarn + +# IDE +.DS_Store diff --git a/mist-invoice-dapp/.yarnrc.yml b/mist-invoice-dapp/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/mist-invoice-dapp/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/mist-invoice-dapp/README.md b/mist-invoice-dapp/README.md new file mode 100644 index 0000000..f4da3c4 --- /dev/null +++ b/mist-invoice-dapp/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/mist-invoice-dapp/jest.config.ts b/mist-invoice-dapp/jest.config.ts new file mode 100644 index 0000000..ccbb0b5 --- /dev/null +++ b/mist-invoice-dapp/jest.config.ts @@ -0,0 +1,42 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +const jestConfig: JestConfigWithTsJest = { + preset: "ts-jest", + testEnvironment: "jsdom", + coveragePathIgnorePatterns: [ + "node_modules", + "src/utils/constants.ts", + "src/index.tsx", + "src/reportWebVitals.ts" + ], + coverageThreshold: { + global: { + branches: 50, + functions: 50, + lines: 50, + statements: 50 + } + }, + setupFilesAfterEnv: ["/jest.setup.ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: { + jsx: "react" + } //"tsconfig.test.json" + } + ], + "node_modules/preact/.+\\.(j|t)sx?$": [ + "ts-jest", + { + tsconfig: { + jsx: "react" + } + } + ] + }, + transformIgnorePatterns: ["node_modules/(?!preact)"] +}; + +export default jestConfig; diff --git a/mist-invoice-dapp/jest.setup.ts b/mist-invoice-dapp/jest.setup.ts new file mode 100644 index 0000000..6082610 --- /dev/null +++ b/mist-invoice-dapp/jest.setup.ts @@ -0,0 +1,17 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom"; +import { TextEncoder } from "util"; + +global.TextEncoder = TextEncoder; +global.matchMedia = + global.matchMedia || + function () { + return { + matches: false, + addListener: function () {}, + removeListener: function () {}, + }; + }; diff --git a/mist-invoice-dapp/next.config.js b/mist-invoice-dapp/next.config.js new file mode 100644 index 0000000..767719f --- /dev/null +++ b/mist-invoice-dapp/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/mist-invoice-dapp/package.json b/mist-invoice-dapp/package.json new file mode 100644 index 0000000..a13b46f --- /dev/null +++ b/mist-invoice-dapp/package.json @@ -0,0 +1,79 @@ +{ + "name": "test-next-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "jest --coverage --watchAll", + "graphql-codegen": "codegen-graph-ts pull https://api.thegraph.com/subgraphs/name/psparacino/xdai-smart-invoices > ./src/graphql/manifest.json && codegen-graph-ts gen -s ./src/graphql/manifest.json -o ./src/graphql/subgraph.ts" + }, + "dependencies": { + "@chakra-ui/icon": "^3.1.0", + "@chakra-ui/react": "^2.8.0", + "@chakra-ui/styled-system": "^2.9.1", + "@chakra-ui/system": "^2.6.0", + "@chakra-ui/theme": "^3.2.0", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@ethersproject/address": "^5.7.0", + "@gnosis.pm/safe-apps-sdk": "^7.8.0", + "@gnosis.pm/safe-apps-web3modal": "^17.0.4", + "@openzeppelin/merkle-tree": "^1.0.5", + "@synthetixio/wei": "^2.74.4", + "@types/node": "20.5.1", + "@types/react": "18.2.20", + "@types/react-dom": "18.2.7", + "@usemist/sdk": "^0.1.0", + "@walletconnect/encoding": "^1.0.2", + "@walletconnect/web3-provider": "^1.8.0", + "@zk-kit/incremental-merkle-tree": "^1.1.0", + "autoprefixer": "10.4.15", + "bufferutil": "^4.0.7", + "crypto-js": "^4.1.1", + "encoding": "^0.1.13", + "eslint": "8.47.0", + "eslint-config-next": "13.4.19", + "ethers": "^6.7.1", + "framer-motion": "^10.16.0", + "graphql-scalars": "^1.22.2", + "jest-environment-jsdom": "^29.6.2", + "next": "13.4.19", + "poseidon-lite": "^0.2.0", + "postcss": "8.4.28", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "^4.0.11", + "react-indexed-db-hook": "^1.0.14", + "react-is": "^18.2.0", + "react-no-ssr": "^1.1.0", + "react-table": "^7.8.0", + "styled-components": "^6.0.7", + "tailwindcss": "3.3.3", + "ts-jest": "^29.1.1", + "tslib": "^2.6.2", + "typescript": "5.1.6", + "urql": "^4.0.5", + "utf-8-validate": "^6.0.3", + "web3": "^4.1.0", + "web3modal": "^1.9.12" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.0.1", + "@testing-library/react": "^14.0.0", + "@types/jest": "^29.5.3", + "@types/react-no-ssr": "^1.1.3", + "@types/react-table": "^7.7.14", + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.4.0", + "codegen-graph-ts": "^0.1.4", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "graphql": "^16.8.0", + "jest": "^29.6.2", + "prettier": "^3.0.2", + "ts-node": "^10.9.1" + } +} diff --git a/mist-invoice-dapp/postcss.config.js b/mist-invoice-dapp/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/mist-invoice-dapp/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/mist-invoice-dapp/public/next.svg b/mist-invoice-dapp/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/mist-invoice-dapp/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mist-invoice-dapp/public/vercel.svg b/mist-invoice-dapp/public/vercel.svg new file mode 100644 index 0000000..d2f8422 --- /dev/null +++ b/mist-invoice-dapp/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mist-invoice-dapp/src/app/_app.tsx b/mist-invoice-dapp/src/app/_app.tsx new file mode 100644 index 0000000..0ebd91b --- /dev/null +++ b/mist-invoice-dapp/src/app/_app.tsx @@ -0,0 +1,36 @@ +// import "./App.css"; +import App, { AppContext, AppInitialProps, AppProps } from "next/app"; +import { ChakraProvider, ColorModeScript, CSSReset } from "@chakra-ui/react"; +import theme from "@chakra-ui/theme"; +import { Global } from "@emotion/react"; +import React from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { initDB } from "react-indexed-db-hook"; + +import { ErrorHandler } from "../components/ErrorHandler"; +import { Web3ContextProvider } from "../context"; +import { MistContextProvider } from "../context/MistContext"; +import { DBConfig } from "../dbconfig"; +import { Layout } from "../shared/Layout"; +import { globalStyles } from "../theme"; + +initDB(DBConfig); + +const MistApp = ({ Component, pageProps }: AppProps) => ( + + + + + + + + {/* */} + + {/* */} + + + + +); + +export default MistApp; diff --git a/mist-invoice-dapp/src/app/favicon.ico b/mist-invoice-dapp/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/mist-invoice-dapp/src/app/favicon.ico differ diff --git a/mist-invoice-dapp/src/app/globals.css b/mist-invoice-dapp/src/app/globals.css new file mode 100644 index 0000000..20da37e --- /dev/null +++ b/mist-invoice-dapp/src/app/globals.css @@ -0,0 +1,31 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} diff --git a/mist-invoice-dapp/src/app/layout.tsx b/mist-invoice-dapp/src/app/layout.tsx new file mode 100644 index 0000000..bd6f806 --- /dev/null +++ b/mist-invoice-dapp/src/app/layout.tsx @@ -0,0 +1,19 @@ +import "./globals.css"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "MIST Smart Invoice", + description: "Private invoice management", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/mist-invoice-dapp/src/app/page.test.tsx b/mist-invoice-dapp/src/app/page.test.tsx new file mode 100644 index 0000000..b26e3d4 --- /dev/null +++ b/mist-invoice-dapp/src/app/page.test.tsx @@ -0,0 +1,21 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import Home from "../pages/Home"; + +jest.mock("@chakra-ui/react", () => ({ + ...jest.requireActual("@chakra-ui/react"), + useBreakpointValue: () => "md", +})); +jest.mock("next/router", () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})); + +describe("Home", () => { + it("should render", () => { + const view = render(); + + expect(view.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/mist-invoice-dapp/src/app/page.tsx b/mist-invoice-dapp/src/app/page.tsx new file mode 100644 index 0000000..0e9896c --- /dev/null +++ b/mist-invoice-dapp/src/app/page.tsx @@ -0,0 +1,116 @@ +"use client"; +import { + Button, + Flex, + Heading, + Text, + useBreakpointValue, +} from "@chakra-ui/react"; +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { useWeb3 } from "../context"; +import { logError } from "../utils"; + +const Home = () => { + const { connectAccount, account } = useWeb3(); + const router = useRouter(); + const [isMobile, setIsMobile] = useState(false); + const [buttonSize, setButtonSize] = useState("lg"); + + // const _buttonSize = useBreakpointValue({ base: "sm", sm: "md", md: "lg" }); + // setButtonSize(_buttonSize); + + useEffect(() => { + if (window) { + toggleMobileMode(); + window.addEventListener("resize", toggleMobileMode); + } + }); + + const toggleMobileMode = () => { + if (window.innerWidth < 600) { + setIsMobile(true); + } else { + setIsMobile(false); + } + }; + + const createInvoice = async () => { + if (account) { + router.push("/create"); + } else { + try { + await connectAccount(); + router.push("/create"); + } catch { + logError("Couldn't connect web3 wallet"); + } + } + }; + + const viewInvoices = async () => { + if (account) { + router.push("/invoices"); + } else { + try { + await connectAccount(); + router.push("/invoices"); + } catch { + logError("Couldn't connect web3 wallet"); + } + } + }; + + return ( + + + Welcome to Smart Invoice + + + How do you want to get started? + + + + + + + ); +}; + +export default Home; diff --git a/mist-invoice-dapp/src/components/AccountLink.tsx b/mist-invoice-dapp/src/components/AccountLink.tsx new file mode 100644 index 0000000..6c8d425 --- /dev/null +++ b/mist-invoice-dapp/src/components/AccountLink.tsx @@ -0,0 +1,93 @@ +import { Flex, Link, Text } from "@chakra-ui/react"; +import { isAddress } from "@ethersproject/address"; +import React, { useContext, useEffect, useState } from "react"; + +import { Web3Context } from "../context/Web3Context"; +import { theme } from "../theme"; +import { + getProfile, + getAddressLink, + getResolverInfo, + getResolverString, + isKnownResolver, +} from "../utils"; + +export type AccountLinkProps = { + address: string; + chainId?: number; +}; + +export const AccountLink: React.FC = ({ + address: inputAddress, + chainId: inputChainId, +}) => { + const { chainId: walletChainId } = useContext(Web3Context); + const address = inputAddress.toLowerCase(); + const [profile, setProfile] = useState(); + const chainId = inputChainId || walletChainId; + const isResolver = isKnownResolver(chainId, address); + + useEffect(() => { + let isSubscribed = true; + if (!isResolver && isAddress(address)) { + getProfile(address).then((p) => + isSubscribed ? setProfile(p) : undefined + ); + } + return () => { + isSubscribed = false; + }; + }, [address, isResolver]); + + let displayString = getResolverString(chainId, address); + + let imageUrl = isResolver + ? getResolverInfo(chainId, address).logoUrl + : undefined; + + if (!isResolver && profile) { + if (profile.name) { + displayString = profile.name; + } + if (profile.imageUrl) { + imageUrl = profile.imageUrl; + } + } + + return ( + + + + {displayString} + + + ); +}; diff --git a/mist-invoice-dapp/src/components/Container.test.tsx b/mist-invoice-dapp/src/components/Container.test.tsx new file mode 100644 index 0000000..dde4ead --- /dev/null +++ b/mist-invoice-dapp/src/components/Container.test.tsx @@ -0,0 +1,11 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { Container } from "./Container"; + +describe("Container", () => { + it("should render", () => { + const view = render(); + + expect(view.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/mist-invoice-dapp/src/components/Container.tsx b/mist-invoice-dapp/src/components/Container.tsx new file mode 100644 index 0000000..31593fd --- /dev/null +++ b/mist-invoice-dapp/src/components/Container.tsx @@ -0,0 +1,39 @@ +import { Flex } from "@chakra-ui/react"; +import React from "react"; + +import { isBackdropFilterSupported } from "../utils"; + +export type ContainerProps = { + overlay?: boolean; +}; + +export const Container: React.FC> = ({ + children, + overlay, + ...props +}) => { + const overlayStyles = isBackdropFilterSupported() + ? { + backgroundColor: "black30", + backdropFilter: "blur(8px)", + } + : { + backgroundColor: "black80", + }; + + return ( + + {children} + + ); +}; diff --git a/mist-invoice-dapp/src/components/ErrorHandler.test.tsx b/mist-invoice-dapp/src/components/ErrorHandler.test.tsx new file mode 100644 index 0000000..49caaa7 --- /dev/null +++ b/mist-invoice-dapp/src/components/ErrorHandler.test.tsx @@ -0,0 +1,19 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { ErrorHandler } from "./ErrorHandler"; + +describe("ErrorHandler", () => { + const mockResetErrorBoundary = jest.fn(); + + it("should render", () => { + const error = new Error("Test error"); + const view = render( + , + ); + + expect(view.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/mist-invoice-dapp/src/components/ErrorHandler.tsx b/mist-invoice-dapp/src/components/ErrorHandler.tsx new file mode 100644 index 0000000..c60def0 --- /dev/null +++ b/mist-invoice-dapp/src/components/ErrorHandler.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { FallbackProps } from "react-error-boundary"; + +export const ErrorHandler: React.FC = ({ + error, +}) => ( + <> +

Error

+
{error.message}
+ +); diff --git a/mist-invoice-dapp/src/components/InvoiceDashboardTable.tsx b/mist-invoice-dapp/src/components/InvoiceDashboardTable.tsx new file mode 100644 index 0000000..9653adb --- /dev/null +++ b/mist-invoice-dapp/src/components/InvoiceDashboardTable.tsx @@ -0,0 +1,410 @@ +import { + Button, + Flex, + Text, + Heading, + IconButton, + Image as ChakraImage, + chakra, + Link, + Menu, + MenuButton, + MenuList, + MenuItem, + HStack, + Badge, +} from "@chakra-ui/react"; +import { formatUnits } from "ethers"; +import React, { useMemo } from "react"; +import { useTable, useSortBy, usePagination } from "react-table"; +import { Loader } from "./Loader"; +import { useInvoiceStatus } from "../hooks/useInvoiceStatus"; +import { + dateTimeToDate, + getTokenInfo, + getHexChainId, + unixToDateTime, +} from "../utils"; +import { VerticalDotsIcon } from "../icons/VerticalDots"; +import { RightArrowIcon, LeftArrowIcon } from "../icons/ArrowIcons"; +import { Styles } from "../pages/invoices/InvoicesStyles"; +// import { GenerateInvoicePDFMenuItem } from "./GenerateInvoicePDF"; +import { FilterIcon } from "../icons/FilterIcon"; +import { InvoiceResult } from "@/graphql/subgraph"; +import { Chain, ChainId } from "@/utils"; + +type InvoiceComponentProps = { + invoice: InvoiceResult; +}; + +type InvoiceStatusLabelProps = InvoiceComponentProps & { + onClick?: () => void; + cursor?: string; +}; + +const InvoiceStatusLabel: React.FC = ({ + invoice, + ...props +}) => { + const { funded, label, loading } = useInvoiceStatus(invoice); + const { isLocked, terminationTime } = invoice; + const terminated = terminationTime.gt(Date.now()); + const disputeResolved = label === "Dispute Resolved"; + return ( + + + {loading ? : label} + + + ); +}; + +type InvoiceBadgeProps = InvoiceComponentProps; + +const InvoiceBadge: React.FC = ({ invoice, ...props }) => { + // const { invoiceType } = invoice; //TODO: source this from the subgraph + const invoiceType = "unknown"; + const schemes = { + escrow: { + bg: "rgba(128, 63, 248, 0.3)", + color: "rgba(128, 63, 248, 1)", + }, + instant: { + bg: "rgba(248, 174, 63, 0.3)", + color: "rgba(248, 174, 63, 1)", + }, + unknown: { + bg: "rgba(150,150,150,0.3)", + color: "rgba(150,150,150,1)", + }, + }; + + return ( + + {invoiceType ? invoiceType.toUpperCase() : "UNKNOWN"} + + ); +}; + +export type Router = { + push: (path: string) => void; +}; + +export type InvoiceDashboardTableProps = { + result: InvoiceResult[]; + tokenData: any; + chainId?: number; + router: Router; +}; + +type Details = { + createdAt: string; + projectName: React.ReactElement; + amount: string; + currency: React.ReactElement; + status: React.ReactElement; + action: React.ReactElement; +}; + +export const InvoiceDashboardTable: React.FC = ({ + result, + tokenData, + chainId, + router, +}) => { + const data = useMemo(() => { + if (!chainId || !result || !tokenData) return []; + const dataArray = [] as Details[]; + result.forEach((invoice, index) => { + const { decimals, symbol, image } = getTokenInfo( + chainId as ChainId, + invoice.token, + tokenData + ); + const viewInvoice = () => + router.push( + `/invoice/${getHexChainId(invoice.network as Chain)}/${ + invoice.address + }/` + //${invoice.invoiceType !== "escrow" ? invoice.invoiceType : ""}` + ); + const details = { + createdAt: dateTimeToDate(unixToDateTime(invoice.createdAt.num)), + projectName: ( + + + {invoice.projectName} + + + + ), + amount: formatUnits(invoice.total.num, decimals), + currency: ( + + + {symbol} + + ), + status: ( + + ), + action: ( + + + + + + + Manage + + {/* */} + + + ), + }; + dataArray.push(details); + }); + return dataArray; + }, [chainId, result, tokenData, router]); + + const columns = useMemo( + () => [ + { + Header: "Date Created", + accessor: "createdAt", + }, + { + Header: "Invoice Name/ID", + accessor: "projectName", + }, + { + Header: "Amount", + accessor: "amount", + isnumeric: "true", + }, + { + Header: "Currency", + accessor: "currency", + isnumeric: "true", + }, + { + Header: "Status", + accessor: "status", + }, + // { + // Header: "Action", + // accessor: "action", + // }, + ], + [] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + prepareRow, + // @ts-ignore + page, + // @ts-ignore + canPreviousPage, + // @ts-ignore + canNextPage, + // @ts-ignore + pageOptions, + // @ts-ignore + pageCount, + // @ts-ignore + gotoPage, + // @ts-ignore + nextPage, + // @ts-ignore + previousPage, + state: { + // @ts-ignore + pageIndex, + // @ts-ignore + pageSize, + }, + } = useTable( + { + // @ts-ignore + columns, + data, + // initialState: { pageIndex: 0 }, + }, + useSortBy, + usePagination + ); + + // cell props and getCellProps for individual cell control styling + return ( + + + + My Invoices + + + +
+ + + {headerGroups.map((headerGroup, hg) => ( + // eslint-disable-next-line react/jsx-key + + {headerGroup.headers.map((column, i) => ( + // eslint-disable-next-line react/jsx-key + + ))} + + ))} + + + { + // @ts-ignore + page.map((row, i) => { + prepareRow(row); + return ( + // eslint-disable-next-line react/jsx-key + + { + // @ts-ignore + row.cells.map((cell, index) => { + return ( + // eslint-disable-next-line react/jsx-key + + ); + }) + } + + ); + }) + } + +
+ + {column.render("Header")} + {i !== headerGroup.headers.length - 1 && ( + + + + )} + +
+ {cell.render("Cell")} +
+
+
+ } + onClick={() => previousPage()} + disabled={!canPreviousPage} + /> + + Page {pageIndex + 1} of {pageCount} + + } + onClick={() => nextPage()} + disabled={!canNextPage} + /> +
+
+ ); +}; diff --git a/mist-invoice-dapp/src/components/Loader.test.tsx b/mist-invoice-dapp/src/components/Loader.test.tsx new file mode 100644 index 0000000..5383ce4 --- /dev/null +++ b/mist-invoice-dapp/src/components/Loader.test.tsx @@ -0,0 +1,11 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { Loader } from "./Loader"; + +describe("Loader", () => { + it("should render", () => { + const view = render(); + + expect(view.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/mist-invoice-dapp/src/components/Loader.tsx b/mist-invoice-dapp/src/components/Loader.tsx new file mode 100644 index 0000000..d8ad175 --- /dev/null +++ b/mist-invoice-dapp/src/components/Loader.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +export const Loader = ({ size = "38" }) => { + return ( + + + + + + + + + + + ); +}; diff --git a/mist-invoice-dapp/src/components/NavButton.test.tsx b/mist-invoice-dapp/src/components/NavButton.test.tsx new file mode 100644 index 0000000..7158e78 --- /dev/null +++ b/mist-invoice-dapp/src/components/NavButton.test.tsx @@ -0,0 +1,13 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { NavButton } from "./NavButton"; + +describe("NavButton", () => { + const mockOnClickHandler = jest.fn(); + + it("should render", () => { + const view = render(); + + expect(view.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/mist-invoice-dapp/src/components/NavButton.tsx b/mist-invoice-dapp/src/components/NavButton.tsx new file mode 100644 index 0000000..462e2f0 --- /dev/null +++ b/mist-invoice-dapp/src/components/NavButton.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import { StyledButton } from "./StyledButton"; + +export type NavButtonProps = { + onClick: () => void; +}; + +export const NavButton: React.FC> = ({ + onClick, + children, +}) => ( + + {children} + +); diff --git a/mist-invoice-dapp/src/components/NetworkChangeAlertModal.tsx b/mist-invoice-dapp/src/components/NetworkChangeAlertModal.tsx new file mode 100644 index 0000000..c038248 --- /dev/null +++ b/mist-invoice-dapp/src/components/NetworkChangeAlertModal.tsx @@ -0,0 +1,54 @@ +import { + Modal, + ModalCloseButton, + ModalContent, + ModalOverlay, + ModalHeader, + ModalBody, +} from "@chakra-ui/react"; +import React from "react"; +import { getNetworkName } from "../utils"; + +export type NetworkChangeAlertModalProps = { + showChainChangeAlert: boolean; + setShowChainChangeAlert: (show: boolean) => void; + chainId: number; +}; + +export const NetworkChangeAlertModal: React.FC< + NetworkChangeAlertModalProps +> = ({ showChainChangeAlert, setShowChainChangeAlert, chainId }) => ( + setShowChainChangeAlert(false)} + > + + + + Attention + + +
+ You are changing the network to {getNetworkName(chainId)}. +
+
+
+ You must complete all invoice creation steps on the same chain. +
+ If you have not yet input any information, you can continue. +
+ Otherwise, please return to Step 1 and complete all steps on the same + network. +
+
+ +
+
+); diff --git a/mist-invoice-dapp/src/components/OrderedInput.tsx b/mist-invoice-dapp/src/components/OrderedInput.tsx new file mode 100644 index 0000000..75989eb --- /dev/null +++ b/mist-invoice-dapp/src/components/OrderedInput.tsx @@ -0,0 +1,366 @@ +import { + Flex, + Input, + InputGroup, + InputLeftElement, + Select, + Text, + Textarea, + Tooltip, + VStack, +} from "@chakra-ui/react"; +import React, { useState } from "react"; + +import { QuestionIcon } from "../icons/QuestionIcon"; +import { isValidLink } from "../utils"; + +export type OrderedLinkInputProps = { + label: string; + linkType: string; + setLinkType: (linkType: string) => void; + value: string; + setValue: (value: string) => void; + infoText?: string; + tooltip?: string; + placeholder?: string; + type?: string; + required?: string; +} & React.ComponentProps; + +export const OrderedLinkInput: React.FC = ({ + label, + linkType, + setLinkType, + value, + setValue, + infoText, + tooltip, + placeholder, + type = "text", + required, + ...props +}) => { + const [protocol, setProtocol] = useState(`${linkType}://`); + const [input, setInput] = useState(""); + const [isInvalid, setInvalid] = useState(false); + + return ( + + + + {label} + + {infoText && {infoText}} + {tooltip && ( + + + + )} + + + + {required} + + + + + + + + { + let newInput = e.target.value; + let newProtocol = protocol; + if (newInput.startsWith("https://") && newInput.length > 8) { + newProtocol = "https://"; + newInput = newInput.slice(8); + } else if ( + newInput.startsWith("ipfs://") && + newInput.length > 7 + ) { + newProtocol = "ipfs://"; + newInput = newInput.slice(7); + } + const newValue = newProtocol + newInput; + const isValid = isValidLink(newValue); + setValue(newValue); + setLinkType(newProtocol.substring(0, newProtocol.length - 3)); + setInvalid(!isValid); + setInput(newInput); + setProtocol(newProtocol); + }} + placeholder={placeholder} + color="black" + border="1px" + borderColor="lightgrey" + _hover={{ borderColor: "lightgrey" }} + isInvalid={isInvalid} + _invalid={{ border: "1px solid", borderColor: "red" }} + /> + + {isInvalid && ( + + Invalid URL + + )} + + + ); +}; + +export type OrderedInputProps = { + label: string; + value: string; + setValue: (value: string) => void; + infoText?: string; + tooltip?: string; + placeholder?: string; + type?: string; + required?: string; + isInvalid?: boolean; + isDisabled?: boolean; + error?: string; +} & React.ComponentProps; + +export const OrderedInput: React.FC = ({ + label, + value, + setValue, + infoText, + tooltip, + placeholder, + required, + isInvalid = false, + isDisabled = false, + type = "text", + error = "", + ...props +}) => { + return ( + + + + {label} + + {infoText && ( + + {infoText} + + )} + {tooltip && ( + + + + )} + + + + {required} + + + + setValue(e.target.value)} + placeholder={placeholder} + color="black" + border="1px" + borderColor="lightgrey" + _hover={{ borderColor: "lightgrey" }} + isDisabled={isDisabled} + isInvalid={isInvalid} + _invalid={{ border: "1px solid", borderColor: "red" }} + /> + {error && ( + + {error} + + )} + + + ); +}; + +export type OrderedSelectProps = { + label: string; + value: string; + setValue: (value: string) => void; + infoText?: string; + tooltip?: string; + required?: string; + isDisabled?: boolean; +}; + +export const OrderedSelect: React.FC< + React.PropsWithChildren +> = ({ + label, + value, + setValue, + infoText, + tooltip, + required, + isDisabled = false, + children, +}) => { + return ( + + + + {label} + + {infoText && ( + + {infoText} + + )} + {tooltip && ( + + + + )} + + + + {required} + + + + + ); +}; + +export type OrderedTextareaProps = { + label: string; + value: string; + setValue: (value: string) => void; + infoText?: string; + tooltip?: string; + placeholder?: string; + maxLength?: number; + required?: string; + isDisabled?: boolean; + type?: string; +}; + +export const OrderedTextarea: React.FC = ({ + label, + value, + setValue, + infoText, + tooltip, + placeholder, + maxLength, + required, + isDisabled = false, + type = "text", +}) => { + return ( + + + + + {label} + + {tooltip && ( + + + + )} + + + {infoText && ( + + {infoText}{" "} + {required && ( + • {required} + )} + + )} + {required && !infoText && ( + + {required} + + )} + + +