├── .editorconfig ├── .env.example ├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── LICENSE ├── README.md ├── arguments.js ├── components ├── CFButton │ ├── CFButton.module.css │ └── index.tsx ├── CFContractNotification │ ├── CFContractNotification.module.css │ └── index.tsx ├── CFDropDown │ ├── CFDropDown.module.css │ └── index.tsx ├── CFFooter │ ├── CFFooter.module.css │ └── index.tsx ├── CFIconLabel │ ├── CFIconLabel.module.css │ └── index.tsx ├── CFInput │ ├── CFInput.module.css │ └── index.tsx ├── CFNumberIndicator │ ├── CFNumberIndicator.module.css │ └── index.tsx ├── CFTextWithIcon │ ├── CFTextWithIcon.module.css │ └── index.tsx ├── CFUser │ ├── CFUser.module.css │ └── index.tsx ├── Loading.tsx ├── Navbar.tsx └── UserProfileDropDown.tsx ├── contracts ├── Donation.sol ├── GitHubFunctions.sol ├── Ledger.sol └── test │ ├── LinkToken.sol │ └── MockV3Aggregator.sol ├── functions ├── get-wallet-and-repos-from-gist.js ├── get-wallet-and-repos-from-gist.spec.js ├── github-metric-times-ether.js └── github-metric-times-ether.spec.js ├── hardhat.config.ts ├── hooks ├── useGoogleTagManager.ts ├── useKeyFocus.tsx ├── useListen.tsx └── useMetaMask.tsx ├── jest.config.mjs ├── logic-overview.png ├── networks.d.ts ├── networks.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx └── index.tsx ├── postcss.config.js ├── public ├── apple-touch-icon.png ├── assets │ ├── ChainlinkLogoWhite.svg │ └── ChainlinkSymbolBlue.svg ├── favicon-16x16.ico ├── favicon-32x32.ico ├── favicon.ico ├── icons │ ├── add-user.svg │ ├── avatar.svg │ ├── caret.svg │ ├── close.svg │ ├── help-tip.svg │ ├── info.svg │ ├── link.svg │ ├── logo.svg │ ├── matic.svg │ ├── sad.svg │ ├── shield.svg │ └── star.svg └── logos │ ├── github.svg │ ├── glow.svg │ ├── metamask.svg │ ├── phantom.svg │ ├── slope.svg │ ├── solfare.svg │ └── walletconnect.svg ├── sections ├── About │ ├── About.module.css │ ├── data.ts │ └── index.tsx ├── ClaimSection │ ├── ClaimSection.module.css │ ├── ClaimSection.tsx │ ├── data.ts │ └── index.tsx ├── ContractProgress │ ├── ContractProgress.module.css │ └── index.tsx └── ContractSection │ ├── ContractSection.module.css │ ├── data.ts │ └── index.tsx ├── styles └── globals.css ├── tailwind.config.js ├── tasks ├── deploy-calculator.js ├── index.js ├── index.ts ├── simulateScript.js └── upgradeOrDeploy.ts ├── tsconfig.json └── types.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | 12 | [*.{ts,tsx}] 13 | quote_type = single 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Google analytics 2 | # NEXT_PUBLIC_GA_TRACKING_ID= 3 | # NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING=GTM-xxxxxx 4 | 5 | # Blockchain 6 | PRIVATE_KEY= 7 | NEXT_PUBLIC_CONTRACT_ADDRESS= 8 | NEXT_PUBLIC_SUBSCRIPTION_ID= 9 | ETHERSCAN_API_KEY= 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:@typescript-eslint/recommended", 8 | "next", 9 | "next/core-web-vitals", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 12, 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["@typescript-eslint"], 21 | "overrides": [ 22 | { 23 | "files": [ 24 | "functions/*.js" 25 | ], 26 | "parserOptions": { 27 | "ecmaFeatures": { 28 | "globalReturn": true 29 | } 30 | } 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # Hardhat config / compile 40 | artifacts 41 | cache/ 42 | typechain-types/ 43 | 44 | # Chainlink Functions beta 45 | FunctionsSandboxLibrary/ 46 | tasks/Functions-billing/ 47 | tasks/Functions-client/ 48 | tasks/utils/ 49 | tasks/accounts.js 50 | tasks/balance.js 51 | tasks/block-number.js 52 | .openzeppelin/ 53 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | semi: true, 4 | singleQuote: true, 5 | tabWidth: 2, 6 | useTabs: false, 7 | }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 SmartContract Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Note** 2 | > 3 | > _This demo represents an educational example to use a Chainlink system, product, or service and is provided to demonstrate how to interact with Chainlink’s systems, products, and services to integrate them into your own. This template is provided “AS IS” and “AS AVAILABLE” without warranties of any kind, it has not been audited, and it may be missing key checks or error handling to make the usage of the system, product or service more clear. Do not use the code in this example in a production environment without completing your own audits and application of best practices. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for unintended outputs that are generated due to errors in code._ 4 | 5 | # Chainlink Functions Demo App 6 | 7 | This is an example dApp, designed to run on the Mumbai testnet (Polygon), that uses [Chainlink Functions](https://docs.chain.link/chainlink-functions/). The functionality allows users to donate MATIC to their favorite GitHub creators. Authors of those repositories can then claim their donations. Donations are made in an amount of MATIC per amount of Stars the repository has. 8 | 9 | Chainlink Functions is used to determine the total donation amount by multiplying the MATIC amount by the star count. There's no back-end involved in the whole donation process. 10 | 11 | **NOTE**: This example is not production ready, as edge cases are not handled. 12 | 13 | ## Usage 14 | 15 | ### Prerequisites 16 | 17 | Before being able to deploy the application, you'll need [MetaMask](https://metamask.io/) installed and a wallet on the Mumbai testnet. The latter is used to create and manage the Ledger contract used by this dApp. Tracking interaction between accounts gives a better insight into the dApp's functioning. Therefore, using a different wallet for contract creation and dApp usage is preferable. After installing MetaMask, go to [chainlist.org](*https://chainlist.org/?testnets=true&search=polygon*) and select the Mumbai chain. This will open a prompt from MetaMask. Now either register your existing wallet or create a new account. 18 | 19 | You'll need both MATIC and LINK for testing, deploying and funding. Testnet faucets can provide these. For example, Chainlink has [a faucet](https://faucets.chain.link/mumbai) that provides both tokens at once. 20 | 21 | A supported Node.js version. This project has been built using Node.js hydrogen. For NVM users, simply run `nvm use`. 22 | 23 | > **NOTE for Mac users** Apple's implementation of `tar` has the `--wildcards` flag implied. You'll either need to remove it from the package.json scripts, or use `gtar`. 24 | 25 | ### Steps 26 | 27 | Run these from the project directory where you've cloned this repo. 28 | 29 | 1. `npm install` or `yarn install` to install all dependencies. 30 | 2. Create a `.env` file, either by making a copy of `.env.example` or starting from scratch. See the chapter [Configuration](#configuration) for more details on the available settings. 31 | - You'll need to set at least the `PRIVATE_KEY` variable. To get your key: click on the MetaMask icon; click the three dots; go to account details; and export your private key. 32 | 3. Generate and build all required files by running `npm build`. This downloads the required files, compiles the Solidity contracts and builds the Nextjs project. 33 | 4. Deploy the Ledger contract with `npx hardhat project:deploy`. 34 | 5. Store the returned address in the `NEXT_PUBLIC_CONTRACT_ADDRESS` environment variable. 35 | 6. (optional) Verify the contract. This allows you to decode the bytecode on Polygonscan. 36 | 1. Create an account on [Polygonscan](polygonscan.com). Note that you'll need to create an account for the main network, which works just as well for the testnet. 37 | 2. Under your account, go to "API Keys". 38 | 3. Add a new key. 39 | 4. Copy your token and save it as the `ETHERSCAN_API_KEY` environment variable. 40 | 5. Verify the contract with `npx hardhat verify --constructor-args arguments.js $NEXT_PUBLIC_CONTRACT_ADDRESS`. (Replace `$NEXT_PUBLIC_CONTRACT_ADDRESS` with your contract address if you don't have the address in your shell environment). 41 | 42 | 7. Create a Chainlink Functions subscription and fund it [here](https://functions.chain.link). 43 | 8. Store the subscription id in the `NEXT_PUBLIC_SUBSCRIPTION_ID` environment variable. 44 | 9. Run the application. 45 | 1. Serve the build. 46 | 2. Or run the dev server with `npm dev`. 47 | 48 | ### Configuration 49 | 50 | - `PRIVATE_KEY` - Private key used for deploying contracts. 51 | - `NEXT_PUBLIC_GA_TRACKING_ID` - Set to your Google Analytics tracking id to enable GA. 52 | - `NEXT_PUBLIC_CONTRACT_ADDRESS` - Where the GH calculator is deployed. 53 | - `NEXT_PUBLIC_SUBSCRIPTION_ID` - ID of the subscription which has the contract at `NEXT_PUBLIC_CONTRACT_ADDRESS` as a consumer. 54 | - `ETHERSCAN_API_KEY` - API key for Polygonscan. Not required, it can be used to verify and read contracts. 55 | 56 | ### Scripts 57 | 58 | - `build` - Creates a production-ready build. 59 | - `dev` - Runs the local development server with HMR. 60 | - `start` - Starts a server to host the build. 61 | - `lint` - Searches for lint in the project. 62 | - `test` - Runs the test suite; the project comes with tests for the functions. 63 | - `update-beta` - Retrieves the latest beta files for Chainlink Functions. 64 | 65 | ## Architecture 66 | 67 | This dApp consists of two parts: the contracts and the web UI. 68 | 69 | Central to the web3 logic is the `Ledger` contract. It is an upgradeable and ownable contract that handles both donations and payouts. In order to verify GitHub metric data and authenticate GitHub users, it uses Chainlink Functions to make off-chain API calls. The contract can be used in a stand-alone fashion. The UI is just a simple app that allows users to interface with the contract. There is no required logic or storage in the web2 part. 70 | 71 | ### Overview 72 | 73 | ![A diagram outlining the structure of the application](./logic-overview.png) 74 | 75 | ### Donation flow 76 | 77 | One can call the `donate` method of the `Ledger` contract directly. It requires a value to be sent along with it, which will then be stored in a new `Donation` contract. 78 | 79 | The whole flow, however, includes making the calculation through Chainlink Functions first. The `Ledger` offers a `multiplyMetricWithEther` method which takes a repository, whether you want to use stars or forks as a metric, and the amount of MATIC to donate per target reached. When the calculation is done, the contract will emit an event and gives you the amount to donate in WEI. 80 | 81 | The web UI takes this number and automatically calls the `donate` method with the found number. 82 | 83 | ### Payout flow 84 | 85 | As the payout does not require additional confirmation from the end user, it consists of a single method on the `Ledger` contract. One can call `claim` with a gist URL. That gist should contain one file containing their own wallet address. 86 | 87 | Chainlink Functions will then read that wallet address and the gist's owner (i.e., GitHub account). If the address found and the address of the requesting party does not match, the execution will stop. Otherwise, the values are returned to the contract, which, in turn, checks if it has any unclaimed donations made to a repository by the given GitHub account. These donations are paid out and removed from the list of donations to track. 88 | 89 | ### Folder Structure 90 | 91 | - `contracts/` - Contains the GitHub calculator contract, which uses Chainlink Functions to calculate the total amount owed. It also contains the helper code provided by Chainlink. 92 | - `functions/` - These are JavaScript scripts which run off-chain through Chainlink Functions. 93 | - `components`/`hooks`/`pages`/`public`/`sections`/`styles` - Are all part of the Next.JS application. 94 | - `tasks/` - Contains the Hardhat tasks to assist in managing the dApp. 95 | 96 | ## Disclaimer 97 | > :warning: **Disclaimer**: The code used in this Chainlink Functions quickstart template comes from Chainlink community members and has not been audited. The Chainlink team disclaims and shall have no liability with respect to any loss, malfunction, or any other result of deploying a Quickstart Template. By electing to deploy a Quickstart Template you hereby acknowledge and agree to the above. 98 | -------------------------------------------------------------------------------- /arguments.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('node:path'); 3 | const { networks } = require('./networks'); 4 | const functionsPath = path.resolve(__dirname, 'functions'); 5 | const checkScriptPath = path.resolve( 6 | functionsPath, 7 | 'get-wallet-and-repos-from-gist.js' 8 | ); 9 | const calculateScriptPath = path.resolve( 10 | functionsPath, 11 | 'github-metric-times-ether.js' 12 | ); 13 | const checkScript = fs.readFileSync(checkScriptPath, { 14 | encoding: 'utf-8', 15 | }); 16 | const calculateScript = fs.readFileSync(calculateScriptPath, { 17 | encoding: 'utf-8', 18 | }); 19 | module.exports = [ 20 | networks.mumbai.functionsRouter, 21 | // calculateScript, 22 | // checkScript, 23 | ]; 24 | -------------------------------------------------------------------------------- /components/CFButton/CFButton.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .button_wrapper { 4 | @apply transition-opacity delay-1000 py-[10px] px-4 text-text-lg font-semibold text-gray-800 bg-blue-200 rounded-md hover:bg-blue-300 disabled:bg-blue-300 disabled:opacity-50; 5 | } 6 | 7 | .button_md_wrapper { 8 | @apply py-2 text-text-md; 9 | } 10 | -------------------------------------------------------------------------------- /components/CFButton/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import styles from './CFButton.module.css'; 3 | 4 | interface Props { 5 | text: string; 6 | disabled?: boolean; 7 | size?: 'md' | 'lg'; 8 | onClick?: () => void; 9 | } 10 | 11 | const CFButton = ({ text, size, onClick, disabled, ...rest }: Props) => { 12 | const btnClasses = classNames(styles.button_wrapper, { 13 | [styles.button_md_wrapper]: size === 'md', 14 | }); 15 | return ( 16 | 19 | ); 20 | }; 21 | 22 | export default CFButton; 23 | -------------------------------------------------------------------------------- /components/CFContractNotification/CFContractNotification.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .card { 4 | @apply w-full lg:w-[460px] py-6 px-10 flex flex-col justify-center items-center bg-white-alpha-50 border border-white-alpha-300 rounded-md; 5 | } 6 | 7 | .outer_circle { 8 | @apply w-[88px] h-[88px] rounded-full bg-white-alpha-100 flex justify-center items-center; 9 | } 10 | .circle { 11 | @apply w-16 h-16 rounded-full flex justify-center items-center flex-shrink; 12 | } 13 | 14 | .message { 15 | @apply mt-6 mb-10 text-gray-300 font-normal text-base; 16 | } 17 | -------------------------------------------------------------------------------- /components/CFContractNotification/index.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from '@heroicons/react/24/outline'; 2 | import styles from './CFContractNotification.module.css'; 3 | import classNames from 'classnames'; 4 | import Image from 'next/image'; 5 | import CFButton from '@components/CFButton'; 6 | 7 | type Status = 'success' | 'fail'; 8 | interface Props { 9 | status: Status; 10 | onClear: () => void; 11 | content: Record; 12 | } 13 | 14 | const CFContractNotification = ({ status, onClear, content }: Props) => { 15 | const circleClasses = classNames(styles.circle, { 16 | 'bg-green-300': status === 'success', 17 | 'bg-red-300': status === 'fail', 18 | }); 19 | const data = content[status]; 20 | 21 | return ( 22 |
23 |
24 |
25 | {status === 'fail' ? ( 26 | sad face 33 | ) : ( 34 | 35 | )} 36 |
37 |
38 |
{data.message}
39 | 40 | 41 |
42 | ); 43 | }; 44 | 45 | export default CFContractNotification; 46 | -------------------------------------------------------------------------------- /components/CFDropDown/CFDropDown.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .list_btn { 4 | @apply relative h-12 w-full cursor-default rounded-md bg-white-alpha-50 py-2 pl-3 pr-10 text-left border focus:outline-none border-white-alpha-300 focus-visible:border-blue-200 disabled:opacity-50; 5 | } 6 | 7 | .options { 8 | @apply absolute z-20 mt-3.5 max-h-60 w-full overflow-auto bg-gray-800 text-base shadow-lg focus:outline-none sm:text-sm; 9 | } 10 | 11 | .option { 12 | @apply relative cursor-pointer select-none py-4 pl-10 pr-4 text-white; 13 | } 14 | -------------------------------------------------------------------------------- /components/CFDropDown/index.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from 'react'; 2 | import styles from './CFDropDown.module.css'; 3 | import { Listbox, Transition } from '@headlessui/react'; 4 | import { ChevronDownIcon } from '@heroicons/react/24/outline'; 5 | import classNames from 'classnames'; 6 | interface Option { 7 | name: string; 8 | } 9 | interface Props { 10 | options: Option[]; 11 | defaultValue: Option; 12 | onChange?: (value: Option) => void; 13 | } 14 | const CFDropDown = ({ options, defaultValue, onChange }: Props) => { 15 | const [selected, setSelected] = useState(defaultValue); 16 | 17 | const handleOnChange = (value: Option) => { 18 | setSelected(value); 19 | onChange && onChange(value); 20 | }; 21 | 22 | if (options.length == 0) { 23 | return null; 24 | } 25 | 26 | return ( 27 | 28 |
29 | 30 | {selected.name} 31 | 32 | 37 | 38 | 44 | 45 | {options.map((option, optionIdx) => ( 46 | 49 | classNames(styles.option, { 50 | 'bg-gray-700': selected.name === option.name, 51 | }) 52 | } 53 | value={option} 54 | > 55 | {({ selected }) => ( 56 | <> 57 | 63 | {option.name} 64 | 65 | 66 | )} 67 | 68 | ))} 69 | 70 | 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default CFDropDown; 77 | -------------------------------------------------------------------------------- /components/CFFooter/CFFooter.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .container { 4 | @apply flex flex-col-reverse md:flex-row justify-between items-center text-text-sm text-gray-500 font-medium gap-2 px-4; 5 | } 6 | 7 | .copyright { 8 | @apply flex items-center gap-1; 9 | } 10 | 11 | .terms_privacy { 12 | @apply flex items-center gap-6; 13 | } 14 | -------------------------------------------------------------------------------- /components/CFFooter/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from './CFFooter.module.css'; 2 | 3 | const CFFooter = () => { 4 | const year = new Date().getFullYear(); 5 | 6 | return ( 7 | 19 | ); 20 | }; 21 | 22 | export default CFFooter; 23 | -------------------------------------------------------------------------------- /components/CFIconLabel/CFIconLabel.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .container { 4 | @apply flex gap-4 items-start; 5 | } 6 | 7 | .content { 8 | @apply text-gray-300 text-sm text-left; 9 | } 10 | -------------------------------------------------------------------------------- /components/CFIconLabel/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import styles from './CFIconLabel.module.css'; 3 | 4 | interface Props { 5 | icon: string; 6 | text: string; 7 | } 8 | 9 | const CFIconLabel = ({icon, text}: Props) => { 10 | return ( 11 |
12 | {`${icon} 13 | {text} 14 |
15 | ); 16 | }; 17 | 18 | export default CFIconLabel; 19 | -------------------------------------------------------------------------------- /components/CFInput/CFInput.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .container { 4 | @apply flex flex-col gap-2; 5 | } 6 | 7 | .input_container { 8 | @apply flex gap-3 py-3 px-4 border bg-white-alpha-50 rounded-md items-center h-12; 9 | } 10 | 11 | .input { 12 | @apply placeholder:text-white-alpha-500 text-gray-100 w-full h-full bg-transparent outline-none; 13 | } 14 | 15 | .missing_field { 16 | @apply text-red-300 font-medium text-text-xs; 17 | } 18 | 19 | .icon_with_text { 20 | @apply flex items-center gap-3 pr-6 border-r border-r-white-alpha-300 uppercase; 21 | } 22 | -------------------------------------------------------------------------------- /components/CFInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useCallback, useState } from 'react'; 2 | import styles from './CFInput.module.css'; 3 | import classNames from 'classnames'; 4 | import Image from 'next/image'; 5 | 6 | interface Props { 7 | type: 'url' | 'text'; 8 | iconType?: 'matic' | 'link' | null; 9 | placeholder: string; 10 | onInput: (value: string) => void; 11 | base?: string; 12 | } 13 | 14 | const CFInput = ({ type, iconType, placeholder, onInput, base }: Props) => { 15 | const [error, setError] = useState(false); 16 | const [onFocus, setOnFocus] = useState(false); 17 | const [value, setValue] = useState(''); 18 | const containerClasses = classNames(styles.container); 19 | const inputContainerClasses = classNames(styles.input_container, { 20 | 'border-red-500': error, 21 | 'border-blue-200': onFocus, 22 | 'border-white-alpha-300': !error && !onFocus, 23 | }); 24 | 25 | const handleBlur = useCallback( 26 | (e: ChangeEvent) => { 27 | const str = e.target.value.trim(); 28 | if (typeof value === 'string' || typeof str === 'string') { 29 | setError(str.length === 0 ? true : false); 30 | } else { 31 | setError(str === 0 ? true : false); 32 | } 33 | setOnFocus(false); 34 | }, 35 | [value] 36 | ); 37 | 38 | const handleInput = (e: ChangeEvent) => { 39 | const str = e.target.value.trim(); 40 | setValue(str); 41 | onInput(str); 42 | setError(str.length === 0 ? true : false); 43 | }; 44 | const getIcon = () => { 45 | if (iconType === 'matic') { 46 | return ( 47 |
48 | matic icon 54 | Matic 55 |
56 | ); 57 | } else if (iconType === 'link') { 58 | return ( 59 | matic icon 60 | ); 61 | } 62 | return null; 63 | }; 64 | 65 | return ( 66 |
67 |
68 | {getIcon()} 69 | { 72 | setOnFocus(true); 73 | setError(false); 74 | }} 75 | onBlur={handleBlur} 76 | onInput={handleInput} 77 | className={styles.input} 78 | placeholder={placeholder} 79 | value={base} 80 | /> 81 |
82 | {error && ( 83 | Fill in this field 84 | )} 85 |
86 | ); 87 | }; 88 | 89 | export default CFInput; 90 | -------------------------------------------------------------------------------- /components/CFNumberIndicator/CFNumberIndicator.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .wrapper { 4 | @apply w-9 h-9 transition-colors duration-1000 delay-300 ease-in-out rounded-full flex justify-center items-center font-semibold border-2; 5 | } 6 | -------------------------------------------------------------------------------- /components/CFNumberIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import styles from './CFNumberIndicator.module.css'; 3 | 4 | interface IProps { 5 | count: number; 6 | status: 'done' | 'in progress' | 'pending'; 7 | } 8 | 9 | const CFNumberIndicator = ({ count, status }: IProps) => { 10 | const indicatorClass = cn(styles.wrapper, { 11 | 'border-blue-200': status === 'done', 12 | 'border-blue-200 border-dashed animate-spin-slow': status === 'in progress', 13 | 'border-gray-600': 14 | status === 'pending' || (status !== 'done' && status !== 'in progress'), 15 | }); 16 | const textClass = cn(styles.wrapper, 'border-none', 'absolute', 'top-0', { 17 | 'text-gray-800 bg-blue-200': status === 'done', 18 | 'text-blue-200 bg-white-50': status === 'in progress', 19 | 'text-gray-300 bg-white-50': 20 | status === 'pending' || (status !== 'done' && status !== 'in progress'), 21 | }); 22 | return ( 23 |
24 | {count} 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default CFNumberIndicator; 31 | -------------------------------------------------------------------------------- /components/CFTextWithIcon/CFTextWithIcon.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .wrapper { 4 | @apply flex items-center flex-nowrap gap-2; 5 | } 6 | 7 | .tip { 8 | @apply absolute top-3 rounded-md px-4 py-2 w-[260px] bg-gray-600 text-gray-100 before:absolute before:content-[''] before:w-4 before:h-4 before:rotate-45 before:p-0 before:left-[20%] before:bg-gray-600 before:-top-4 before:z-[-1]; 9 | } 10 | 11 | .tip::before { 12 | @apply absolute w-8 h-8 rotate-45 left-[20%]; 13 | } 14 | -------------------------------------------------------------------------------- /components/CFTextWithIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import cn from 'classnames'; 3 | import styles from './CFTextWithIcon.module.css'; 4 | 5 | interface IProps { 6 | text: string; 7 | tip: string; 8 | } 9 | const CFTextWithIcon = ({text, tip}: IProps) => { 10 | return ( 11 | 12 | {text} 13 | 14 | {text} 15 |
{tip}
16 |
17 |
18 | ); 19 | }; 20 | 21 | export default CFTextWithIcon; 22 | -------------------------------------------------------------------------------- /components/CFUser/CFUser.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .user_wrapper { 4 | @apply flex items-center gap-6 py-[10px] px-4 rounded-md bg-white-alpha-50; 5 | } 6 | 7 | .wallet_avatar { 8 | @apply flex items-center gap-3; 9 | } 10 | 11 | .balance { 12 | @apply text-gray-200 font-semibold text-text-sm; 13 | } 14 | -------------------------------------------------------------------------------- /components/CFUser/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import styles from './CFUser.module.css'; 3 | import { useMetaMask } from '../../hooks/useMetaMask'; 4 | 5 | 6 | const CFUser = () => { 7 | const { 8 | state: { wallet, balance }, 9 | } = useMetaMask(); 10 | const slicedWallet = wallet?.slice(0, 6) + '...' + wallet?.slice(-4); 11 | const calcBalance = (parseInt(balance || '') / 1000000000000000000).toFixed( 12 | 4 13 | ); 14 | const imageUrl = './icons/avatar.svg'; 15 | 16 | return ( 17 |
18 | {calcBalance} MATIC 19 |
20 | {slicedWallet} 21 | avatar 28 | caret 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default CFUser; 35 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { type FC } from 'react'; 2 | 3 | const dot = `rounded-full h-2 w-2 mx-0.5 bg-current animate-[blink_1s_ease_0s_infinite_normal_both]"`; 4 | const style = { animationDelay: '0.2s' }; 5 | 6 | export const Loading: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { Disclosure } from '@headlessui/react'; 3 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; 4 | 5 | import UserProfileDropDown from './UserProfileDropDown'; 6 | import CFButton from './CFButton'; 7 | import { useListen } from 'hooks/useListen'; 8 | import { useMetaMask } from '../hooks/useMetaMask'; 9 | import Link from 'next/link'; 10 | 11 | export default function Navbar() { 12 | const { 13 | dispatch, 14 | state: { status: metaStatus, isMetaMaskInstalled, balance }, 15 | } = useMetaMask(); 16 | const listen = useListen(); 17 | 18 | const showInstallMetaMask = 19 | metaStatus !== 'pageNotLoaded' && !isMetaMaskInstalled; 20 | 21 | const handleConnect = async () => { 22 | dispatch({ type: 'loading' }); 23 | const accounts = await window.ethereum.request({ 24 | method: 'eth_requestAccounts', 25 | }); 26 | 27 | if (accounts.length > 0) { 28 | const balance = await window.ethereum?.request({ 29 | method: 'eth_getBalance', 30 | params: [accounts[0], 'latest'], 31 | }); 32 | 33 | dispatch({ type: 'connect', wallet: accounts[0], balance }); 34 | 35 | // we can register an event listener for changes to the users wallet 36 | listen(); 37 | } 38 | }; 39 | return ( 40 | 41 | {({ open }) => ( 42 | <> 43 |
44 |
45 |
46 | {/* Mobile menu button*/} 47 | 48 | Open main menu 49 | {open ? ( 50 | 55 |
56 |
57 |
58 | Your Company 65 |
66 |
67 |
68 | {balance ? ( 69 | 70 | ) : ( 71 | <> 72 | {showInstallMetaMask ? ( 73 | 78 | Install MetaMask 79 | 80 | ) : ( 81 | 86 | )} 87 | 88 | )} 89 |
90 |
91 |
92 | 93 | )} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /components/UserProfileDropDown.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | import { Menu, Transition } from '@headlessui/react'; 3 | import CFUser from './CFUser'; 4 | import { useMetaMask } from '../hooks/useMetaMask'; 5 | 6 | function classNames(...classes: string[]) { 7 | return classes.filter(Boolean).join(' '); 8 | } 9 | 10 | export default function UserProfileDropDown() { 11 | const { dispatch } = useMetaMask(); 12 | 13 | const handleDisconnect = () => { 14 | dispatch({ type: 'disconnect' }); 15 | }; 16 | return ( 17 | 18 |
19 | 20 | Open user menu 21 | 22 | 23 |
24 | 33 | 34 | 35 | {({ active }) => ( 36 | { 42 | handleDisconnect(); 43 | }} 44 | > 45 | Disconnect Wallet 46 | 47 | )} 48 | 49 | 50 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /contracts/Donation.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | 4 | contract Donation { 5 | string public repository; 6 | address internal owner; 7 | 8 | event Paid(uint amount, address by); 9 | 10 | constructor(string memory _repository, address _owner) payable { 11 | repository = _repository; 12 | owner = _owner; 13 | } 14 | 15 | function payout(address payable _to) public { 16 | require(msg.sender == owner, "Only the ledger can request a payout"); 17 | uint value = address(this).balance; 18 | (bool sent, ) = _to.call{value: value}(""); 19 | require(sent, "Failed to pay out donation"); 20 | 21 | emit Paid(value, _to); 22 | } 23 | 24 | /// @notice Shows the repository this donation was made to 25 | function getRepository() public view returns (string memory) { 26 | return repository; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /contracts/GitHubFunctions.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.0; 3 | 4 | import {FunctionsClient} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/FunctionsClient.sol"; 5 | import {FunctionsRequest} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/libraries/FunctionsRequest.sol"; 6 | 7 | contract GitHubFunctions is FunctionsClient { 8 | using FunctionsRequest for FunctionsRequest.Request; 9 | 10 | event OCRResponse(bytes32 indexed requestId, bytes result, bytes err); 11 | 12 | string public calculationLogic; 13 | bytes32 public latestRequestId; 14 | bytes public latestResponse; 15 | bytes public latestError; 16 | bytes32 public donId; 17 | 18 | constructor(address oracle, bytes32 _donId, string memory _calculationLogic) FunctionsClient(oracle) { 19 | donId = _donId; 20 | calculationLogic = _calculationLogic; 21 | } 22 | 23 | function multiplyMetricWithEther( 24 | string[] calldata args, 25 | uint64 subscriptionId, 26 | uint32 gasLimit 27 | ) public returns (bytes32) { 28 | FunctionsRequest.Request memory req; 29 | req.initializeRequest(FunctionsRequest.Location.Inline, FunctionsRequest.CodeLanguage.JavaScript, calculationLogic); 30 | req.setArgs(args); 31 | 32 | bytes32 assignedReqID = _sendRequest(req.encodeCBOR(), subscriptionId, gasLimit, donId); 33 | latestRequestId = assignedReqID; 34 | return assignedReqID; 35 | } 36 | 37 | /** 38 | * @notice Callback that is invoked once the DON has resolved the request or hit an error 39 | * 40 | * @param requestId The request ID, returned by sendRequest() 41 | * @param response Aggregated response from the user code 42 | * @param err Aggregated error from the user code or from the execution pipeline 43 | * Either response or error parameter will be set, but never both 44 | */ 45 | function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) internal override { 46 | latestResponse = response; 47 | latestError = err; 48 | emit OCRResponse(requestId, response, err); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /contracts/Ledger.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 4 | import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; 5 | import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; 6 | import "@openzeppelin/contracts/utils/Strings.sol"; 7 | 8 | import {FunctionsClient} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/FunctionsClient.sol"; 9 | import {FunctionsRequest} from "@chainlink/contracts/src/v0.8/functions/dev/v1_0_0/libraries/FunctionsRequest.sol"; 10 | 11 | import "./Donation.sol"; 12 | 13 | /// @title Record of which donations are yet to be claimed 14 | /// @notice The contract stores donations that have ETH attached to them and allows users to make new donations as well as to claim these donations. 15 | /// @dev This contract utilizes Chainlink Functions and serves as a demo application 16 | contract Ledger is Initializable, UUPSUpgradeable, OwnableUpgradeable, FunctionsClient { 17 | using FunctionsRequest for FunctionsRequest.Request; 18 | 19 | address[] internal unclaimedDonations; 20 | mapping(address => Donation) internal donationMap; 21 | mapping(bytes32 => address) internal runningClaims; 22 | string internal calculationLogic; 23 | string internal checkLogic; 24 | bytes32 public donId; 25 | 26 | event OCRResponse(bytes32 indexed requestId, bytes result, bytes err); 27 | event Claimed(uint amount, address by); 28 | 29 | constructor(address oracle) FunctionsClient(oracle) {} 30 | 31 | /// @notice Allows for constructor arguments and for these to be passed on when upgrading 32 | /// @param _donId DON ID for the Functions DON to which the requests are sent 33 | /// @param _calculationLogic JavaScript that can calculate the amount of ETH owed for a pledge 34 | /// @param _checkLogic JavaScript that verifies the senders identity with GitHub and returns the repositories to pay out for 35 | function initialize(bytes32 _donId, string calldata _calculationLogic, string calldata _checkLogic) public initializer { 36 | donId = _donId; 37 | calculationLogic = _calculationLogic; 38 | checkLogic = _checkLogic; 39 | __Ownable_init(); 40 | } 41 | 42 | /// @notice Pledges the attached amount of ETH to given `_repository` 43 | function donate(string calldata _repository) external payable { 44 | Donation donation = new Donation{value: msg.value}(_repository, address(this)); 45 | donationMap[address(donation)] = donation; 46 | unclaimedDonations.push(address(donation)); 47 | } 48 | 49 | /// @notice Calculates the amount of ETH to donate 50 | function multiplyMetricWithEther( 51 | string calldata _repository, 52 | string calldata _metric, 53 | string calldata _target, 54 | string calldata _amount, 55 | uint64 subscriptionId 56 | ) external returns (bytes32) { 57 | FunctionsRequest.Request memory req; 58 | req.initializeRequest(FunctionsRequest.Location.Inline, FunctionsRequest.CodeLanguage.JavaScript, calculationLogic); 59 | string[] memory args = new string[](4); 60 | args[0] = _repository; 61 | args[1] = _metric; 62 | args[2] = _target; 63 | args[3] = _amount; 64 | req.setArgs(args); 65 | 66 | bytes32 assignedReqID = _sendRequest(req.encodeCBOR(), subscriptionId, 300000, donId); 67 | 68 | return assignedReqID; 69 | } 70 | 71 | /// @notice Can be called by maintainers to claim donations made to their repositories 72 | function claim(string calldata _gist, uint64 subscriptionId) public { 73 | FunctionsRequest.Request memory req; 74 | req.initializeRequest(FunctionsRequest.Location.Inline, FunctionsRequest.CodeLanguage.JavaScript, checkLogic); 75 | 76 | string[] memory args = new string[](2); 77 | args[0] = _gist; 78 | args[1] = Strings.toHexString(uint256(uint160(msg.sender)), 20); 79 | 80 | req.setArgs(args); 81 | bytes32 assignedReqID = _sendRequest(req.encodeCBOR(), subscriptionId, 300000, donId); 82 | runningClaims[assignedReqID] = msg.sender; 83 | } 84 | 85 | /// @notice Finalizes the claim process after Chainlink Functions has finished the authentication 86 | function finalizeClaim(address payable _maintainer, string memory _login) internal { 87 | uint _total = 0; 88 | uint _number = 0; 89 | 90 | for (uint i = 0; i < unclaimedDonations.length;) { 91 | Donation _current = donationMap[unclaimedDonations[i]]; 92 | uint _balance = unclaimedDonations[i].balance; 93 | 94 | if (_balance > 0 && containsWord(_login, _current.repository())) { 95 | _total += _balance; 96 | _number++; 97 | _current.payout(_maintainer); 98 | delete donationMap[unclaimedDonations[i]]; 99 | } 100 | unchecked { i++; } 101 | } 102 | 103 | address[] memory _unclaimedDonations = new address[](unclaimedDonations.length - _number); 104 | uint j = 0; 105 | 106 | for (uint i = 0; i < unclaimedDonations.length;) { 107 | if (unclaimedDonations[i].balance > 0) { 108 | _unclaimedDonations[j] = unclaimedDonations[i]; 109 | unchecked { j++; } 110 | } 111 | unchecked{ i++; } 112 | } 113 | unclaimedDonations = _unclaimedDonations; 114 | 115 | emit Claimed(_total, _maintainer); 116 | } 117 | 118 | /// @notice Helper function to see if repository is by maintainer 119 | /// @dev pulled from https://github.com/HermesAteneo/solidity-repeated-word-in-string/blob/main/RepeatedWords.sol#L46 120 | function containsWord (string memory what, string memory where) internal pure returns (bool found){ 121 | bytes memory whatBytes = bytes (what); 122 | bytes memory whereBytes = bytes (where); 123 | 124 | if(whereBytes.length < whatBytes.length){ return false; } 125 | 126 | found = false; 127 | for (uint i = 0; i <= whereBytes.length - whatBytes.length; i++) { 128 | bool flag = true; 129 | for (uint j = 0; j < whatBytes.length; j++) 130 | if (whereBytes [i + j] != whatBytes [j]) { 131 | flag = false; 132 | break; 133 | } 134 | if (flag) { 135 | found = true; 136 | break; 137 | } 138 | } 139 | 140 | return found; 141 | } 142 | 143 | /// @notice Callback that is invoked once the DON has resolved the request or hit an error 144 | /// 145 | /// @param requestId The request ID, returned by sendRequest() 146 | /// @param response Aggregated response from the user code 147 | /// @param err Aggregated error from the user code or from the execution pipeline 148 | /// Either response or error parameter will be set, but never both 149 | function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) internal override { 150 | emit OCRResponse(requestId, response, err); 151 | 152 | if (response.length > 0 && runningClaims[requestId] != address(0)) { 153 | string memory login = string(response); 154 | finalizeClaim(payable(runningClaims[requestId]), login); 155 | } 156 | } 157 | 158 | /// @notice View to see which donations are still open 159 | function getDonations() public view returns (address[] memory) { 160 | return unclaimedDonations; 161 | } 162 | 163 | /// @notice The current version of the contract, useful for development purposes 164 | function getVersion() public pure returns (uint8) { 165 | return 3; 166 | } 167 | 168 | function _authorizeUpgrade(address) internal override onlyOwner {} 169 | } 170 | -------------------------------------------------------------------------------- /contracts/test/LinkToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.4.24; 3 | 4 | import "@chainlink/contracts/src/v0.4/LinkToken.sol"; 5 | -------------------------------------------------------------------------------- /contracts/test/MockV3Aggregator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.7.0; 3 | 4 | import "@chainlink/contracts/src/v0.7/tests/MockV3Aggregator.sol"; 5 | -------------------------------------------------------------------------------- /functions/get-wallet-and-repos-from-gist.js: -------------------------------------------------------------------------------- 1 | const [url, sender] = args; 2 | 3 | if (!/^https:\/\/gist\.github\.com/.test(url)) { 4 | throw new Error('supplied URL must be a GitHub gist'); 5 | } 6 | const id = url.split('/').at(-1); 7 | const { data } = await Functions.makeHttpRequest({ 8 | url: `https://api.github.com/gists/${id}`, 9 | }); 10 | 11 | const files = Object.values(data.files); 12 | if (files.length === 0) { 13 | throw new Error('There are no files in the provided gist'); 14 | } 15 | 16 | const wallet = Object.values(data.files)[0].content; 17 | if (wallet.slice(0, 2) !== '0x' || wallet.length < 34 || wallet.length > 62) { 18 | throw new Error('Gist does not contain a valid address'); 19 | } 20 | if (sender.toLowerCase() !== wallet.toLowerCase()) { 21 | throw new Error('Sender and found address do not match'); 22 | } 23 | 24 | // noinspection JSAnnotator 25 | return Functions.encodeString(data.owner.login); 26 | -------------------------------------------------------------------------------- /functions/get-wallet-and-repos-from-gist.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | 4 | describe('Function "get wallet and repos from gist"', () => { 5 | let functionBody; 6 | beforeAll(async () => { 7 | const location = path.resolve( 8 | __dirname, 9 | 'get-wallet-and-repos-from-gist.js' 10 | ); 11 | functionBody = await fs.readFile(location, { 12 | encoding: 'UTF-8', 13 | }); 14 | 15 | global.Functions = { 16 | makeHttpRequest: jest.fn(), 17 | encodeString: jest.fn((input) => input), 18 | }; 19 | }); 20 | 21 | afterAll(() => { 22 | global.Functions = undefined; 23 | }); 24 | 25 | it('returns the gist owner', async () => { 26 | Functions.makeHttpRequest.mockImplementationOnce(async () => ({ 27 | data: { 28 | files: { 29 | 'wallet.txt': { 30 | content: '0x016720dA88226C8Ff537c9133A2a9361f1FACaE7', 31 | }, 32 | }, 33 | owner: { 34 | login: 'mbicknese', 35 | }, 36 | }, 37 | })); 38 | const result = await eval( 39 | `(async (args) => {${functionBody}})(['https://gist.github.com/mbicknese/e07195bc1f833c90eb6356841156189b', '0x016720dA88226C8Ff537c9133A2a9361f1FACaE7'])` 40 | ); 41 | expect(result).toBe('mbicknese'); 42 | }); 43 | 44 | const scenarios = [ 45 | ['notanaddress'], 46 | ['0x0'], 47 | ['0x123456789123456789123456789123456789123456789123456789123456789'], 48 | ]; 49 | it.each(scenarios)('checks for a valid address', async (wallet) => { 50 | Functions.makeHttpRequest.mockImplementationOnce(async () => ({ 51 | data: { 52 | files: { 53 | someFile: { 54 | content: wallet, 55 | }, 56 | }, 57 | }, 58 | })); 59 | await expect( 60 | eval( 61 | `(async (args) => {${functionBody}})(['https://gist.github.com/mbicknese/e07195bc1f833c90eb6356841156189b', '${wallet}'])` 62 | ) 63 | ).rejects.toThrow('Gist does not contain a valid address'); 64 | }); 65 | 66 | it('rejects unauthorised claims', async () => { 67 | Functions.makeHttpRequest.mockImplementationOnce(async () => ({ 68 | data: { 69 | files: { 70 | someFile: { 71 | content: '0x016720dA88226C8Ff537c9133A2a9361f1FACaE7', 72 | }, 73 | }, 74 | }, 75 | })); 76 | await expect( 77 | eval( 78 | `(async (args) => {${functionBody}})(['https://gist.github.com/mbicknese/e07195bc1f833c90eb6356841156189b', '0xotherwalletaddress'])` 79 | ) 80 | ).rejects.toThrow('Sender and found address do not match'); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /functions/github-metric-times-ether.js: -------------------------------------------------------------------------------- 1 | const [repo, metric, target, amount] = args; 2 | 3 | if (metric !== 'stars' && metric !== 'forks') { 4 | throw new Error('Metric needs to be either "stars" or "forks"'); 5 | } 6 | const metricKey = { stars: 'stargazers_count', forks: 'forks_count' }[metric]; 7 | 8 | const url = repo 9 | .replace('github.com/', 'github.com/repos/') 10 | .replace(/(https:\/\/)(.*)(github\.com.*)/, '$1api.$3'); 11 | const res = await Functions.makeHttpRequest({ url }); 12 | const metricCount = +res.data[metricKey]; 13 | if (typeof metricCount !== 'number') { 14 | throw new Error('Could not get the amount of metric for repo ' + repo); 15 | } 16 | if (metricCount === 0) { 17 | // noinspection JSAnnotator 18 | return Functions.encodeUint256(0); 19 | } 20 | 21 | const result = Math.floor(metricCount / +target) * +amount; 22 | 23 | // noinspection JSAnnotator 24 | return Functions.encodeUint256(result); 25 | -------------------------------------------------------------------------------- /functions/github-metric-times-ether.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | 4 | describe('Function "github metric times ether"', () => { 5 | let functionBody; 6 | beforeAll(async () => { 7 | const location = path.resolve(__dirname, 'github-metric-times-ether.js'); 8 | functionBody = await fs.readFile(location, { 9 | encoding: 'UTF-8', 10 | }); 11 | 12 | global.Functions = { 13 | makeHttpRequest: jest.fn(), 14 | encodeUint256: jest.fn((input) => input), 15 | }; 16 | }); 17 | 18 | afterAll(() => { 19 | global.Functions = undefined; 20 | }); 21 | 22 | const url = 23 | 'https://github.com/smartcontractkit/functions-hardhat-starter-kit'; 24 | const scenarios = [ 25 | [10, 1, url, 'forks', '5', '1000000', 2_000_000], 26 | [1, 10, url, 'stars', '5', '1000000', 2_000_000], 27 | [1, 0, url, 'forks', '5', '1000000', 0], 28 | [0, 1, url, 'stars', '5', '1000000', 0], 29 | [26, 1, url, 'forks', '3', '1200000', 9_600_000], 30 | ]; 31 | it.each(scenarios)( 32 | 'calculates the right amount', 33 | async (forks, stars, url, metric, target, amount, expected) => { 34 | Functions.makeHttpRequest.mockImplementationOnce(async () => ({ 35 | data: { 36 | forks_count: forks, 37 | stargazers_count: stars, 38 | }, 39 | })); 40 | const result = await eval( 41 | `(async (args) => {${functionBody}})([url, metric, target, amount])` 42 | ); 43 | expect(result).toBe(expected); 44 | } 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from 'hardhat/config'; 2 | import '@nomicfoundation/hardhat-toolbox'; 3 | import '@nomiclabs/hardhat-ethers'; 4 | import '@openzeppelin/hardhat-upgrades'; 5 | import '@nomiclabs/hardhat-etherscan'; 6 | 7 | import dotenv from 'dotenv'; 8 | dotenv.config(); 9 | 10 | import './tasks/index.ts'; 11 | 12 | const settings = { 13 | optimizer: { 14 | enabled: true, 15 | runs: 1_000, 16 | }, 17 | }; 18 | const config: HardhatUserConfig = { 19 | solidity: { 20 | compilers: [ 21 | { 22 | version: '0.8.19', 23 | settings, 24 | }, 25 | { 26 | version: '0.7.6', 27 | settings, 28 | }, 29 | { 30 | version: '0.4.24', 31 | settings, 32 | }, 33 | ], 34 | }, 35 | networks: { 36 | mumbai: { 37 | url: 'https://polygon-mumbai.g.alchemy.com/v2/tCbwTAqlofFnmbVORepuHNcsrjNXWdRJ', 38 | accounts: [process.env.PRIVATE_KEY || ''], 39 | }, 40 | }, 41 | defaultNetwork: 'mumbai', 42 | etherscan: { 43 | apiKey: process.env.ETHERSCAN_API_KEY, 44 | }, 45 | paths: { 46 | sources: './contracts', 47 | tests: './test', 48 | cache: './cache', 49 | artifacts: './build/artifacts', 50 | }, 51 | }; 52 | 53 | export default config; 54 | -------------------------------------------------------------------------------- /hooks/useGoogleTagManager.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import TagManager from 'react-gtm-module'; 3 | 4 | const useGoogleTagManager = (trackingCode?: string) => { 5 | useEffect(() => { 6 | if (trackingCode) { 7 | TagManager.initialize({ gtmId: trackingCode }); 8 | } 9 | }, [trackingCode]); 10 | }; 11 | 12 | export default useGoogleTagManager; 13 | -------------------------------------------------------------------------------- /hooks/useKeyFocus.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | 3 | /** 4 | * Element focus uses Control + keyboard input to trigger 5 | * @param key numerical value of key input 6 | * @param ref ref to element to targe focus on 7 | */ 8 | export default function useKeyFocus( 9 | key: number, 10 | ref: RefObject 11 | ) { 12 | useEffect(() => { 13 | function hotkeyPress(e: KeyboardEvent) { 14 | if (e.ctrlKey && e.keyCode === key && ref?.current) { 15 | e.preventDefault(); 16 | if (ref?.current) { 17 | ref.current.focus(); 18 | } 19 | return; 20 | } 21 | } 22 | 23 | document.addEventListener('keydown', hotkeyPress); 24 | return () => document.removeEventListener('keydown', hotkeyPress); 25 | }, [key, ref]); 26 | } 27 | -------------------------------------------------------------------------------- /hooks/useListen.tsx: -------------------------------------------------------------------------------- 1 | import { useMetaMask } from './useMetaMask'; 2 | 3 | export const useListen = () => { 4 | const { dispatch } = useMetaMask(); 5 | 6 | return () => { 7 | window.ethereum.on('accountsChanged', async (newAccounts: string[]) => { 8 | if (newAccounts.length > 0) { 9 | // uppon receiving a new wallet, we'll request again the balance to synchronize the UI. 10 | const newBalance = await window.ethereum!.request({ 11 | method: 'eth_getBalance', 12 | params: [newAccounts[0], 'latest'], 13 | }); 14 | 15 | dispatch({ 16 | type: 'connect', 17 | wallet: newAccounts[0], 18 | balance: newBalance, 19 | }); 20 | } else { 21 | // if the length is 0, then the user has disconnected from the wallet UI 22 | dispatch({ type: 'disconnect' }); 23 | } 24 | }); 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /hooks/useMetaMask.tsx: -------------------------------------------------------------------------------- 1 | import React, { type PropsWithChildren } from 'react'; 2 | 3 | type ConnectAction = { type: 'connect'; wallet: string; balance: string }; 4 | type DisconnectAction = { type: 'disconnect' }; 5 | type PageLoadedAction = { 6 | type: 'pageLoaded'; 7 | isMetaMaskInstalled: boolean; 8 | wallet: string | null; 9 | balance: string | null; 10 | }; 11 | type LoadingAction = { type: 'loading' }; 12 | type IdleAction = { type: 'idle' }; 13 | 14 | type Action = 15 | | ConnectAction 16 | | DisconnectAction 17 | | PageLoadedAction 18 | | LoadingAction 19 | | IdleAction; 20 | 21 | type Dispatch = (action: Action) => void; 22 | 23 | type Status = 'loading' | 'idle' | 'pageNotLoaded'; 24 | 25 | type State = { 26 | wallet: string | null; 27 | isMetaMaskInstalled: boolean; 28 | status: Status; 29 | balance: string | null; 30 | }; 31 | 32 | const initialState: State = { 33 | wallet: null, 34 | isMetaMaskInstalled: false, 35 | status: 'loading', 36 | balance: null, 37 | } as const; 38 | 39 | function metaMaskReducer(state: State, action: Action): State { 40 | switch (action.type) { 41 | case 'connect': { 42 | const { wallet, balance } = action; 43 | const newState = { ...state, wallet, balance, status: 'idle' } as State; 44 | const info = JSON.stringify(newState); 45 | window.localStorage.setItem('MetaMaskState', info); 46 | return newState; 47 | } 48 | case 'disconnect': { 49 | window.localStorage.removeItem('MetaMaskState'); 50 | return { ...state, wallet: null, balance: null }; 51 | } 52 | case 'pageLoaded': { 53 | const { isMetaMaskInstalled, balance, wallet } = action; 54 | return { 55 | ...state, 56 | isMetaMaskInstalled: isMetaMaskInstalled, 57 | status: 'idle', 58 | wallet, 59 | balance, 60 | }; 61 | } 62 | case 'loading': { 63 | return { ...state, status: 'loading' }; 64 | } 65 | case 'idle': { 66 | return { ...state, status: 'idle' }; 67 | } 68 | default: { 69 | throw new Error('Unhandled action type'); 70 | } 71 | } 72 | } 73 | 74 | const MetaMaskContext = React.createContext< 75 | { state: State; dispatch: Dispatch } | undefined 76 | >(undefined); 77 | 78 | function MetaMaskProvider({ children }: PropsWithChildren) { 79 | const [state, dispatch] = React.useReducer(metaMaskReducer, initialState); 80 | const value = { state, dispatch }; 81 | 82 | return ( 83 | 84 | {children} 85 | 86 | ); 87 | } 88 | 89 | function useMetaMask() { 90 | const context = React.useContext(MetaMaskContext); 91 | if (context === undefined) { 92 | throw new Error('useMetaMask must be used within a MetaMaskProvider'); 93 | } 94 | return context; 95 | } 96 | 97 | export { MetaMaskProvider, useMetaMask }; 98 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import nextJest from 'next/jest.js'; 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }); 7 | 8 | // Add any custom config to be passed to Jest 9 | /** @type {import('jest').Config} */ 10 | const config = { 11 | // Add more setup options before each test is run 12 | // setupFilesAfterEnv: ['/jest.setup.js'], 13 | 14 | // Enable when testing React components 15 | // testEnvironment: 'jest-environment-jsdom', 16 | }; 17 | 18 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 19 | export default createJestConfig(config); 20 | -------------------------------------------------------------------------------- /logic-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smartcontractkit/chainlink-functions-demo-app/91af26b24c9dee0a879660b5b178c5cb9d44a42c/logic-overview.png -------------------------------------------------------------------------------- /networks.d.ts: -------------------------------------------------------------------------------- 1 | export type Network = { 2 | linkToken: string; 3 | linkEthPriceFeed: string; 4 | functionsRouter: string; 5 | donId: string; 6 | }; 7 | 8 | export const networks: Record; 9 | -------------------------------------------------------------------------------- /networks.js: -------------------------------------------------------------------------------- 1 | const networks = { 2 | mumbai: { 3 | linkToken: '0x326C977E6efc84E512bB9C30f76E30c160eD06FB', 4 | linkEthPriceFeed: '0x12162c3E810393dEC01362aBf156D7ecf6159528', 5 | functionsRouter: '0x6E2dc0F9DB014aE19888F539E59285D2Ea04244C', 6 | donId: 'fun-polygon-mumbai-1', 7 | }, 8 | }; 9 | 10 | module.exports = { 11 | networks, 12 | }; 13 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chainlink-functions", 3 | "version": "0.2.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "hardhat compile && next dev", 7 | "build": "hardhat compile && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@chainlink/contracts": "^0.7.1", 14 | "@chainlink/functions-toolkit": "^0.2.4", 15 | "@headlessui/react": "^1.7.13", 16 | "@heroicons/react": "^2.0.16", 17 | "@openzeppelin/contracts": "^4.8.3", 18 | "@openzeppelin/contracts-upgradeable": "^4.8.3", 19 | "@types/react-gtm-module": "^2.0.1", 20 | "axios": "^1.3.4", 21 | "dotenv": "^16.0.3", 22 | "eth-crypto": "^2.6.0", 23 | "ethers": "^5.5.1", 24 | "is-http-url": "^2.0.0", 25 | "jest": "^29.5.0", 26 | "next": "13.2.3", 27 | "ora": "^6.3.1", 28 | "prompt-sync": "^4.2.0", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-gtm-module": "^2.0.11", 32 | "readline-promise": "^1.0.5", 33 | "ts-node": "^10.9.1", 34 | "vm2": "^3.9.16", 35 | "wagmi": "^0.12.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.21.0", 39 | "@ethersproject/abi": "^5.7.0", 40 | "@ethersproject/bytes": "^5.7.0", 41 | "@ethersproject/providers": "^5.7.2", 42 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.0", 43 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 44 | "@nomicfoundation/hardhat-toolbox": "^2.0.2", 45 | "@nomiclabs/hardhat-ethers": "^2.0.0", 46 | "@nomiclabs/hardhat-etherscan": "^3.1.0", 47 | "@openzeppelin/hardhat-upgrades": "^1.22.1", 48 | "@typechain/ethers-v5": "^10.1.0", 49 | "@typechain/hardhat": "^6.1.2", 50 | "@types/node": "^18.14.6", 51 | "@types/react": "^18.0.28", 52 | "@types/react-dom": "18.0.11", 53 | "@typescript-eslint/eslint-plugin": "^5.54.1", 54 | "@typescript-eslint/parser": "^5.54.1", 55 | "autoprefixer": "^10.4.13", 56 | "classnames": "^2.3.2", 57 | "eslint": "8.35.0", 58 | "eslint-config-next": "13.2.3", 59 | "eslint-config-prettier": "^8.7.0", 60 | "eslint-import-resolver-typescript": "^3.5.3", 61 | "eslint-plugin-import": "^2.27.5", 62 | "eslint-plugin-prettier": "^4.2.1", 63 | "hardhat": "^2.13.0", 64 | "hardhat-gas-reporter": "^1.0.8", 65 | "postcss": "^8.4.21", 66 | "prettier": "^2.8.4", 67 | "solidity-coverage": "^0.8.1", 68 | "tailwindcss": "^3.2.7", 69 | "typechain": "^8.1.1", 70 | "typescript": "4.9.5" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app'; 2 | import Script from 'next/script'; 3 | 4 | import { MetaMaskProvider } from '../hooks/useMetaMask'; 5 | import useGoogleTagManager from '../hooks/useGoogleTagManager'; 6 | import '../styles/globals.css'; 7 | 8 | export default function App({ Component, pageProps }: AppProps) { 9 | useGoogleTagManager(process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_TRACKING); 10 | 11 | return ( 12 | <> 13 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import Navbar from '@components/Navbar'; 3 | import { useListen } from '../hooks/useListen'; 4 | import { useMetaMask } from '../hooks/useMetaMask'; 5 | import ContractSection from 'sections/ContractSection'; 6 | import ClaimSection from 'sections/ClaimSection'; 7 | import About from 'sections/About'; 8 | 9 | export default function IndexPage() { 10 | const { dispatch } = useMetaMask(); 11 | const listen = useListen(); 12 | 13 | useEffect(() => { 14 | if (typeof window !== undefined) { 15 | // start by checking if window.ethereum is present, indicating a wallet extension 16 | const ethereumProviderInjected = typeof window.ethereum !== 'undefined'; 17 | // this could be other wallets, so we can verify if we are dealing with MetaMask 18 | // using the boolean constructor to be explicit and not let this be used as a falsy value (optional) 19 | const isMetaMaskInstalled = 20 | ethereumProviderInjected && Boolean(window.ethereum.isMetaMask); 21 | 22 | const local = window.localStorage.getItem('MetaMaskState'); 23 | 24 | // user was previously connected, start listening to MM 25 | if (local) { 26 | listen(); 27 | } 28 | 29 | // local could be null if not present in LocalStorage 30 | const { wallet, balance } = local 31 | ? JSON.parse(local) 32 | : // backup if local storage is empty 33 | { wallet: null, balance: null }; 34 | 35 | dispatch({ 36 | type: 'pageLoaded', 37 | isMetaMaskInstalled: isMetaMaskInstalled, 38 | wallet, 39 | balance, 40 | }); 41 | } 42 | 43 | // eslint-disable-next-line react-hooks/exhaustive-deps 44 | }, []); 45 | 46 | return ( 47 | <> 48 | 49 |
50 | 51 | 52 | 53 |
54 |
55 |
56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smartcontractkit/chainlink-functions-demo-app/91af26b24c9dee0a879660b5b178c5cb9d44a42c/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/ChainlinkLogoWhite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/ChainlinkSymbolBlue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon-16x16.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smartcontractkit/chainlink-functions-demo-app/91af26b24c9dee0a879660b5b178c5cb9d44a42c/public/favicon-16x16.ico -------------------------------------------------------------------------------- /public/favicon-32x32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smartcontractkit/chainlink-functions-demo-app/91af26b24c9dee0a879660b5b178c5cb9d44a42c/public/favicon-32x32.ico -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smartcontractkit/chainlink-functions-demo-app/91af26b24c9dee0a879660b5b178c5cb9d44a42c/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/add-user.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/icons/caret.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/icons/close.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/help-tip.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/icons/info.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/icons/link.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/icons/matic.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/icons/sad.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/icons/shield.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/icons/star.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/logos/github.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/logos/glow.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/logos/metamask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/logos/phantom.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /public/logos/slope.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/logos/solfare.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /public/logos/walletconnect.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sections/About/About.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .container { 4 | @apply bg-white-alpha-50 backdrop-blur-[10px] rounded-md p-5 lg:px-0 py-10 my-10; 5 | } 6 | 7 | .content_wrapper { 8 | @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 lg:divide-x divide-white-alpha-100 gap-12 h-full; 9 | } 10 | 11 | .content { 12 | @apply flex flex-col px-2 lg:px-10 text-gray-300 text-sm font-normal gap-5; 13 | } 14 | 15 | .title { 16 | @apply text-white text-lg font-semibold text-left mb-6 w-fit; 17 | } 18 | 19 | .wallets { 20 | @apply bg-white-alpha-50 rounded-md flex items-center p-1.5 gap-2; 21 | } 22 | 23 | .get_started { 24 | @apply space-y-4 -translate-y-2; 25 | } 26 | 27 | .list { 28 | @apply list-inside flex gap-4 items-center; 29 | } 30 | 31 | .for_devs { 32 | @apply list-inside list-disc pl-3; 33 | } 34 | 35 | .github_wrapper { 36 | @apply flex items-center gap-2 hover:underline text-blue-200 hover:text-blue-300 visited:text-purple-200 visited:hover:text-purple-300 37 | font-semibold text-base; 38 | } 39 | -------------------------------------------------------------------------------- /sections/About/data.ts: -------------------------------------------------------------------------------- 1 | export const wallets = [ 2 | { 3 | name: 'metamask', 4 | extension: '.svg', 5 | link: 'https://metamask.io/', 6 | }, 7 | { 8 | name: 'walletconnect', 9 | extension: '.svg', 10 | link: 'https://walletconnect.com/', 11 | }, 12 | ]; 13 | 14 | export const forDevs = [ 15 | { 16 | text: 'How to get started', 17 | link: `${process.env.NEXT_GITHUB_URL}/blob/main/README.md`, 18 | }, 19 | { 20 | text: 'Chainlink Functions Docs', 21 | link: 'https://docs.chain.link/chainlink-functions', 22 | }, 23 | { 24 | text: 'Next JS docs', 25 | link: 'https://nextjs.org/', 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /sections/About/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import styles from './About.module.css'; 3 | import { forDevs, wallets } from './data'; 4 | import { ArrowLongRightIcon } from '@heroicons/react/24/outline'; 5 | import Link from 'next/link'; 6 | 7 | const About = () => { 8 | const github_url = process.env.NEXT_GITHUB_URL; 9 | 10 | return ( 11 |
12 |
13 |
14 |

Purpose

15 |

16 | This dApp will show you how to use Chainlink Functions to bring web2 data on-chain 17 | and use this data in your smart contracts. 18 |

19 |
20 |
21 |

Getting Started

22 |
    23 |
  1. 24 | 1. Install & connect a wallet 25 |
    26 | {wallets.map(({ name, extension, link }, walletIndex) => ( 27 | 28 | {`${name} 34 | 35 | ))} 36 |
    37 |
  2. 38 |
  3. 39 | 2. Enter the repo URL for the creator you want to sponsor 40 |
  4. 41 |
  5. 3. Define the metric by which you will donate (Stars or Forks)
  6. 42 |
  7. 4. Enter the threshold number
  8. 43 |
  9. 44 | 5. Enter the amount of MATIC to donate based on your defined threadhold 45 |
  10. 46 |
47 |
48 |
49 |

For Developers

50 | 51 |

52 | This dApp is built using Chainlink Functions. It enables developers 53 | to use web2 data in web3 smart contracts. Learn 54 | how to build a full-stack dApp with Chainlink Functions. 55 |

56 | 57 | 62 | github logo 68 | View on GitHub 69 | 70 | 71 | 72 |
    73 | {forDevs.map(({ text, link }, index) => ( 74 |
  • 75 | 76 | {text} 77 | 78 |
  • 79 | ))} 80 |
81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default About; 88 | -------------------------------------------------------------------------------- /sections/ClaimSection/ClaimSection.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .container { 4 | @apply bg-white-alpha-50 backdrop-blur-[10px] rounded-md p-5 px-0 lg:px-5 py-10 my-10 grid grid-cols-2 gap-x-52; 5 | } 6 | 7 | .info_heading { 8 | @apply text-white font-semibold text-heading-xl md:text-[40px] leading-[120%] lg:pr-16; 9 | } 10 | 11 | .info_description { 12 | @apply text-gray-300 font-normal text-base mt-5 mb-10 lg:pr-14; 13 | } 14 | 15 | .info_link { 16 | @apply hover:underline text-blue-200 hover:text-blue-300 visited:text-purple-200 visited:hover:text-purple-300 17 | } 18 | 19 | .inputs { 20 | @apply grid gap-4 grid-cols-1 lg:pl-14; 21 | } 22 | -------------------------------------------------------------------------------- /sections/ClaimSection/ClaimSection.tsx: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | import { useState } from 'react'; 3 | 4 | import CFInput from '@components/CFInput'; 5 | import CFButton from '@components/CFButton'; 6 | import CFContractNotification from '@components/CFContractNotification'; 7 | 8 | import LedgerABI from '../../build/artifacts/contracts/Ledger.sol/Ledger.json'; 9 | import ContractProgress from '../ContractProgress'; 10 | import { Ledger } from '../../typechain-types'; 11 | 12 | import { content, steps } from './data'; 13 | import styles from './ClaimSection.module.css'; 14 | 15 | const ClaimSection = () => { 16 | const [gist, setGist] = useState(''); 17 | const [state, setState] = useState< 18 | 'uninitialized' | 'initialized' | 'pending' | 'success' | 'fail' 19 | >('uninitialized'); 20 | type Progress = (typeof steps)[number]['count']; 21 | const [progress, setProgress] = useState(1); 22 | 23 | const handleClaim = () => { 24 | setState('initialized'); 25 | setProgress(1); 26 | const provider = new ethers.providers.Web3Provider(window.ethereum); 27 | const signer = provider.getSigner(); 28 | const ledger = new ethers.Contract( 29 | process.env.NEXT_PUBLIC_CONTRACT_ADDRESS || '', 30 | LedgerABI.abi, 31 | signer 32 | ); 33 | 34 | (async () => { 35 | try { 36 | /** 37 | * All we need to do is call the claim function on our contract. It will verify the identity using Chainlink 38 | * Functions, followed by paying out all relevant donations on the chain. 39 | */ 40 | const receipt = await ( 41 | await (ledger as Ledger).claim( 42 | gist, 43 | process.env.NEXT_PUBLIC_SUBSCRIPTION_ID || '', 44 | { gasLimit: 600_000 } 45 | ) 46 | ).wait(1); 47 | 48 | const requestId = receipt.events?.[0].topics[1]; 49 | 50 | /** 51 | * Multiple calculations may be running at the same time. We're going to poll for new events and wait for 52 | * one that matches our request id. There's a safeguard that stop the execution after 60 seconds. 53 | */ 54 | let result: { args: [string, string, string] } | undefined; 55 | const started = Date.now(); 56 | while (!result && Date.now() - started < 60_000) { 57 | const events = await ledger.queryFilter(ledger.filters.OCRResponse()); // Only get the relevant events 58 | result = events.find((event) => event.args?.[0] === requestId) as 59 | | { args: [string, string, string] } 60 | | undefined; 61 | } 62 | // Bail out if the event didn't fire or the event contains an error response from Chainlink Functions 63 | if (result == null || result.args[2] !== '0x') { 64 | setState('uninitialized'); 65 | throw new Error( 66 | 'Chainlink function did not finish successfully.' + 67 | ethers.BigNumber.from(result?.args[2]).toHexString() 68 | ); 69 | } 70 | 71 | setState('success'); 72 | } catch (e) { 73 | console.log(e); 74 | setState('fail'); 75 | } 76 | })(); 77 | }; 78 | 79 | return ( 80 |
81 |
82 | {state === 'success' || state === 'fail' ? ( 83 | { 86 | setState('uninitialized'); 87 | setGist(''); 88 | }} 89 | content={content} 90 | /> 91 | ) : state === 'uninitialized' ? ( 92 |
93 |

94 | Enter the URL to your gist containing your wallet address here. 95 |

96 | 102 | 107 | 108 | ) : ( 109 | 114 | )} 115 |
116 | 133 |
134 | ); 135 | }; 136 | 137 | export default ClaimSection; 138 | -------------------------------------------------------------------------------- /sections/ClaimSection/data.ts: -------------------------------------------------------------------------------- 1 | import { IStep } from '../ContractProgress'; 2 | 3 | export const steps = [ 4 | { 5 | count: 1, 6 | label: 'Looking for donations', 7 | tip: 'Using Chainlink Functions authenticate your identity and find relevant donations', 8 | }, 9 | ] as const satisfies readonly IStep[]; 10 | 11 | export const content = { 12 | fail: { 13 | message: 'Sorry, something went wrong, please try again', 14 | btnText: 'Try again', 15 | }, 16 | success: { 17 | message: 'All donations have been transferred to your wallet', 18 | btnText: 'Claim another', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /sections/ClaimSection/index.tsx: -------------------------------------------------------------------------------- 1 | import ClaimSection from './ClaimSection'; 2 | export default ClaimSection; 3 | -------------------------------------------------------------------------------- /sections/ContractProgress/ContractProgress.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .wrapper { 4 | @apply bg-white-alpha-50 border border-white-alpha-300 rounded-md p-6; 5 | } 6 | 7 | .heading { 8 | @apply text-white text-xl font-semibold mb-6 text-center; 9 | } 10 | 11 | .steps { 12 | @apply flex justify-center gap-[25px]; 13 | } 14 | 15 | .step { 16 | @apply relative flex flex-col justify-center items-center gap-2; 17 | } 18 | 19 | .step_label { 20 | @apply text-xs text-center w-20 h-8; 21 | } 22 | 23 | .active_label { 24 | @apply text-blue-100; 25 | } 26 | 27 | .separator { 28 | @apply absolute top-4 left-14 z-[-1] h-1 w-[72px] bg-gray-600; 29 | } 30 | 31 | .active_separator { 32 | transition-property: width; 33 | @apply absolute top-0 left-0 z-[-1] h-1 w-0 duration-1000 delay-300 ease-in-out bg-blue-100; 34 | } 35 | .active_separator_done { 36 | @apply w-[100%]; 37 | } 38 | 39 | .progress_tip { 40 | @apply mt-6 text-gray-300 text-center text-sm; 41 | } 42 | -------------------------------------------------------------------------------- /sections/ContractProgress/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | import CFNumberIndicator from '@components/CFNumberIndicator'; 3 | import styles from './ContractProgress.module.css'; 4 | 5 | export interface IStep { 6 | count: number; 7 | label: string; 8 | tip: string; 9 | } 10 | interface IContractProgress { 11 | progress: number; 12 | textData?: Record; 13 | steps: readonly IStep[]; 14 | heading: string; 15 | } 16 | 17 | const indicator_status = { 18 | done: 'done', 19 | in_progress: 'in progress', 20 | pending: 'pending', 21 | } as const; 22 | 23 | const ContractProgress = ({ 24 | progress, 25 | textData = {}, 26 | steps, 27 | heading, 28 | }: IContractProgress) => { 29 | const getStatus = (value: number) => { 30 | return progress === value 31 | ? indicator_status.in_progress 32 | : value > progress 33 | ? indicator_status.pending 34 | : indicator_status.done; 35 | }; 36 | 37 | return ( 38 |
39 |

{heading}

40 |
41 | {steps.map((step) => ( 42 |
43 | 47 | 53 | {step.label} 54 | 55 | {step.count !== steps.at(-1)?.count && ( 56 |
57 |
63 |
64 | )} 65 |
66 | ))} 67 |
68 |
69 | {Object.keys(textData).reduce( 70 | (result, key) => 71 | result.replace(`{${key.toUpperCase()}}`, textData[key]), 72 | steps[progress - 1]?.tip 73 | )} 74 |
75 |
76 | ); 77 | }; 78 | 79 | export default ContractProgress; 80 | -------------------------------------------------------------------------------- /sections/ContractSection/ContractSection.module.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | 3 | .container { 4 | @apply bg-white-alpha-50 backdrop-blur-[10px] rounded-md p-5 px-0 lg:px-5 py-10 my-10 min-h-[336px]; 5 | } 6 | 7 | .content_wrapper { 8 | @apply grid gap-20 grid-cols-1 lg:grid-cols-2 px-2 lg:px-5; 9 | } 10 | 11 | .info_heading { 12 | @apply text-white font-semibold text-heading-xl md:text-[40px] leading-[120%] lg:pr-16; 13 | } 14 | 15 | .info_description { 16 | @apply text-gray-300 font-normal text-base mt-5 mb-10 lg:pr-14; 17 | } 18 | 19 | .info_breakdown { 20 | @apply grid gap-4 grid-cols-1 md:grid-cols-3; 21 | } 22 | 23 | .info_link { 24 | @apply hover:underline text-blue-200 hover:text-blue-300 visited:text-purple-200 visited:hover:text-purple-300 25 | } 26 | 27 | .inputs { 28 | @apply grid gap-4 grid-cols-1 lg:pl-14; 29 | } 30 | 31 | .option_count { 32 | @apply grid gap-4 grid-cols-1 md:grid-cols-2; 33 | } 34 | 35 | .btn_wrapper { 36 | @apply flex justify-center mt-4; 37 | } 38 | -------------------------------------------------------------------------------- /sections/ContractSection/data.ts: -------------------------------------------------------------------------------- 1 | import { IStep } from '../ContractProgress'; 2 | 3 | export const breakdown = [ 4 | { 5 | icon: 'add-user', 6 | text: 'Support GitHub creators', 7 | }, 8 | { 9 | icon: 'star', 10 | text: 'Donate Matic based on repo popularity', 11 | }, 12 | { 13 | icon: 'shield', 14 | text: 'Your GitHub is completely safe', 15 | }, 16 | ]; 17 | 18 | export const contractOptions = [{ name: 'Stars' }, { name: 'Forks' }]; 19 | 20 | export const steps = [ 21 | { 22 | count: 1, 23 | label: 'Calculating MATIC', 24 | tip: 'Using Chainlink Functions to calculate the total amount of MATIC pledged.', 25 | }, 26 | { 27 | count: 2, 28 | label: 'Pledging donation', 29 | tip: 'Chainlink Functions has determined a donation of {AMOUNT} MATIC.', 30 | }, 31 | ] as const satisfies readonly IStep[]; 32 | 33 | export const content = { 34 | success: { 35 | message: 'Your contract has been successfully created', 36 | btnText: 'Execute another contract', 37 | }, 38 | fail: { 39 | message: 'Sorry, something went wrong, please try again', 40 | btnText: 'Try again', 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /sections/ContractSection/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { ethers } from 'ethers'; 3 | 4 | import CFIconLabel from '@components/CFIconLabel'; 5 | import CFInput from '@components/CFInput'; 6 | import CFDropDown from '@components/CFDropDown'; 7 | import CFButton from '@components/CFButton'; 8 | import CFContractNotification from '@components/CFContractNotification'; 9 | import ContractProgress from 'sections/ContractProgress'; 10 | 11 | import LedgerABI from '../../build/artifacts/contracts/Ledger.sol/Ledger.json'; 12 | import { useMetaMask } from '../../hooks/useMetaMask'; 13 | 14 | import styles from './ContractSection.module.css'; 15 | import { breakdown, content, contractOptions, steps } from './data'; 16 | 17 | const ContractSection = () => { 18 | const [calculatedAmount, setCalculatedAmount] = useState(''); 19 | const { state: metaMaskState } = useMetaMask(); 20 | const [matic, setMatic] = useState(0); 21 | const [metric, setMetric] = useState(0); 22 | const [metricType, setMetricType] = useState< 23 | (typeof contractOptions)[number] 24 | >(contractOptions[0]); 25 | type Progress = (typeof steps)[number]['count']; 26 | const [progress, setProgress] = useState(1); 27 | const [repo, setRepo] = useState(undefined); 28 | const [state, setState] = useState< 29 | 'uninitialized' | 'initialized' | 'pending' | 'success' | 'fail' 30 | >('uninitialized'); 31 | 32 | function handleDonation() { 33 | setState('initialized'); 34 | setProgress(1); 35 | const provider = new ethers.providers.Web3Provider(window.ethereum); 36 | const signer = provider.getSigner(); 37 | const ledger = new ethers.Contract( 38 | process.env.NEXT_PUBLIC_CONTRACT_ADDRESS || '', 39 | LedgerABI.abi, 40 | signer 41 | ); 42 | 43 | (async () => { 44 | try { 45 | // Step 1: Have Chainlink Functions calculate the amount of Ether to donate 46 | const calculationTx = await ledger.multiplyMetricWithEther( 47 | `https://github.com/${repo}`, 48 | metricType.name.toLowerCase(), 49 | `${metric}`, 50 | ethers.utils.parseUnits(matic.toString(), 'ether').toString(), 51 | process.env.NEXT_PUBLIC_SUBSCRIPTION_ID, 52 | { 53 | gasLimit: 600_000, 54 | } 55 | ); 56 | const calculationReceiptTx = await calculationTx.wait(1); 57 | // Chainlink Functions give you a request id to track to progress of your execution 58 | const requestId = calculationReceiptTx.events[0].topics[1]; 59 | 60 | /** 61 | * Multiple calculations may be running at the same time. We're going to poll for new events and wait for 62 | * one that matches our request id. There's a safeguard that stop the execution after 60 seconds. 63 | */ 64 | let result: { args: [string, string, string] } | undefined; 65 | const started = Date.now(); 66 | while (!result && Date.now() - started < 60_000) { 67 | // Only get the relevant events 68 | const events = await ledger.queryFilter( 69 | ledger.filters.OCRResponse(), 70 | calculationReceiptTx.blockNumber 71 | ); 72 | result = events.find((event) => event.args?.[0] === requestId) as 73 | | { args: [string, string, string] } 74 | | undefined; 75 | } 76 | 77 | // Bail out if the event didn't fire or the event contains an error response from Chainlink Functions 78 | if (result == null || result.args[2] !== '0x') { 79 | setState('uninitialized'); 80 | throw new Error( 81 | 'Chainlink function did not finish successfully.' + 82 | ethers.BigNumber.from(result?.args[2]).toHexString() 83 | ); 84 | } 85 | 86 | // Step 2: Donate the calculated amount to the ledger 87 | setProgress(2); 88 | const calculatedAmountHex = result.args[1]; 89 | const calculatedAmount = parseInt(calculatedAmountHex, 16); 90 | setCalculatedAmount( 91 | (calculatedAmount / 1_000_000_000_000_000_000).toString() 92 | ); 93 | 94 | await ( 95 | await ledger.donate(`https://github.com/${repo}`, { 96 | value: calculatedAmountHex, 97 | }) 98 | ).wait(1); 99 | setState('success'); 100 | } catch (e) { 101 | console.log(e); 102 | setState('fail'); 103 | } 104 | })(); 105 | } 106 | 107 | return ( 108 |
109 |
110 |
111 |

112 | Sponsor your favorite GitHub creators with Chainlink Functions 113 |

114 |

115 | Contribute to GitHub creators who meet the goals you define. 116 |
117 |
118 | Define a threshold goal for the creator to reach and execute a 119 | one-time donation based on your criteria. 120 |
121 |
122 | 126 | Add your wallet address to the Functions beta preview list to use 127 | this app. 128 | 129 |

130 |
131 | {breakdown.map(({ icon, text }, breakdownIndex) => ( 132 | 133 | ))} 134 |
135 |
136 | 137 |
138 | {state === 'success' || state === 'fail' ? ( 139 | { 142 | setState('uninitialized'); 143 | setMetric(0); 144 | setRepo(undefined); 145 | setMatic(0); 146 | }} 147 | content={content} 148 | /> 149 | ) : ( 150 | <> 151 | {state === 'initialized' ? ( 152 | 158 | ) : ( 159 | <> 160 |
161 | setRepo(value.slice(19))} 167 | /> 168 |
169 |
170 | setMetricType(value)} 174 | /> 175 | setMetric(+value)} 179 | /> 180 |
181 |
182 | setMatic(+value)} 187 | /> 188 |
189 |
190 | 0 && 197 | metric > 0 && 198 | repo && 199 | state === 'uninitialized' && 200 | metaMaskState.wallet 201 | ) 202 | } 203 | /> 204 |
205 | 206 | )} 207 | 208 | )} 209 |
210 |
211 |
212 | ); 213 | }; 214 | 215 | export default ContractSection; 216 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | :root { 8 | --font-family-inter: 'Inter', 9 | -apple-system, 10 | BlinkMacSystemFont, 11 | Segoe UI, 12 | Roboto, 13 | Oxygen, 14 | Ubuntu, 15 | Cantarell, 16 | Fira Sans, 17 | Droid Sans, 18 | Helvetica Neue, 19 | sans-serif; 20 | } 21 | 22 | html, 23 | body { 24 | font-family: var(--font-family-inter); 25 | @apply p-0 m-0; 26 | } 27 | .gradients { 28 | @apply rounded-full blur-[150px] w-[800px] h-[800px] fixed top-0 -z-20; 29 | } 30 | 31 | .green_gradient{ 32 | @apply -left-32 blur-[150px] bg-green-gradient; 33 | } 34 | .blue_gradient{ 35 | @apply -right-32 -top-14 blur-[150px] bg-blue-gradient; 36 | } 37 | 38 | a { 39 | @apply no-underline text-inherit; 40 | } 41 | 42 | * { 43 | @apply box-border; 44 | } 45 | 46 | body { 47 | @apply bg-gray-900 text-white; 48 | } 49 | 50 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | mode: 'jit', 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx}', 6 | './components/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | darkMode: 'media', // or 'media' or 'class' 9 | theme: { 10 | extend: { 11 | screens: { 12 | sm: '30em', 13 | mobile: '48em', 14 | laptop: '62em', 15 | desktop: '80em', 16 | 'desktop-lg': '96em', 17 | }, 18 | fontSize: { 19 | 'heading-4xl': '72px', 20 | 'heading-3xl': '60px', 21 | 'heading-2xl': '48px', 22 | 'heading-xl': '36px', 23 | 'heading-lg': '30px', 24 | 'heading-small-lg': '24px', 25 | 'heading-md': '20px', 26 | 'heading-sm': '16px', 27 | 'heading-xs': '14px', 28 | 'text-6xl': '60px', 29 | 'text-5xl': '48px', 30 | 'text-4xl': '36px', 31 | 'text-3xl': '30px', 32 | 'text-2xl': '24px', 33 | 'text-xl': '20px', 34 | 'text-lg': '18px', 35 | 'text-md': '16px', 36 | 'text-sm': '14px', 37 | 'text-xs': '12px', 38 | }, 39 | colors: { 40 | 'white-alpha-50': 'rgba(255, 255, 255, 0.04)', 41 | 'white-alpha-100': 'rgba(255, 255, 255, 0.06)', 42 | 'white-alpha-200': 'rgba(255, 255, 255, 0.08)', 43 | 'white-alpha-300': 'rgba(255, 255, 255, 0.16)', 44 | 'white-alpha-400': 'rgba(255, 255, 255, 0.24)', 45 | 'white-alpha-500': 'rgba(255, 255, 255, 0.36)', 46 | 'white-alpha-600': 'rgba(255, 255, 255, 0.48)', 47 | 'white-alpha-700': 'rgba(255, 255, 255, 0.64)', 48 | 'white-alpha-800': 'rgba(255, 255, 255, 0.8)', 49 | 'white-alpha-900': 'rgba(255, 255, 255, 0.92)', 50 | 'black-alpha-50': 'rgba(0, 0, 0, 0.04)', 51 | 'black-alpha-100': 'rgba(0, 0, 0, 0.06)', 52 | 'black-alpha-200': 'rgba(0, 0, 0, 0.08)', 53 | 'black-alpha-300': 'rgba(0, 0, 0, 0.16)', 54 | 'black-alpha-400': 'rgba(0, 0, 0, 0.24)', 55 | 'black-alpha-500': 'rgba(0, 0, 0, 0.36)', 56 | 'black-alpha-600': 'rgba(0, 0, 0, 0.48)', 57 | 'black-alpha-700': 'rgba(0, 0, 0, 0.64)', 58 | 'black-alpha-800': 'rgba(0, 0, 0, 0.8)', 59 | 'black-alpha-900': 'rgba(0, 0, 0, 0.92)', 60 | 'gray-50': 'rgba(247, 250, 252, 1)', 61 | 'gray-100': 'rgba(237, 242, 247, 1)', 62 | 'gray-200': 'rgba(226, 232, 240, 1)', 63 | 'gray-300': 'rgba(203, 213, 224, 1)', 64 | 'gray-400': 'rgba(160, 174, 192, 1)', 65 | 'gray-500': 'rgba(113, 128, 150, 1)', 66 | 'gray-600': 'rgba(74, 85, 104, 1)', 67 | 'gray-700': 'rgba(45, 55, 72, 1)', 68 | 'gray-800': 'rgba(26, 32, 44, 1)', 69 | 'gray-900': 'rgba(23, 25, 35, 1)', 70 | 'red-50': 'rgba(255, 245, 245, 1)', 71 | 'red-100': 'rgba(254, 215, 215, 1)', 72 | 'red-200': 'rgba(254, 178, 178, 1)', 73 | 'red-300': 'rgba(252, 129, 129, 1)', 74 | 'red-400': 'rgba(245, 101, 101, 1)', 75 | 'red-500': 'rgba(229, 62, 62, 1)', 76 | 'red-600': 'rgba(197, 48, 48, 1)', 77 | 'red-700': 'rgba(155, 44, 44, 1)', 78 | 'red-800': 'rgba(130, 39, 39, 1)', 79 | 'red-900': 'rgba(99, 23, 27, 1)', 80 | 'green-50': 'rgba(240, 255, 244, 1)', 81 | 'green-100': 'rgba(198, 246, 213, 1)', 82 | 'green-200': 'rgba(154, 230, 180, 1)', 83 | 'green-300': 'rgba(104, 211, 145, 1)', 84 | 'green-400': 'rgba(72, 187, 120, 1)', 85 | 'green-500': 'rgba(56, 161, 105, 1)', 86 | 'green-600': 'rgba(37, 133, 90, 1)', 87 | 'green-700': 'rgba(39, 103, 73, 1)', 88 | 'green-800': 'rgba(34, 84, 61, 1)', 89 | 'green-900': 'rgba(28, 69, 50, 1)', 90 | 'blue-50': 'rgba(235, 248, 255, 1)', 91 | 'blue-100': 'rgba(190, 227, 248, 1)', 92 | 'blue-200': 'rgba(144, 205, 244, 1)', 93 | 'blue-300': 'rgba(99, 179, 237, 1)', 94 | 'blue-400': 'rgba(66, 153, 225, 1)', 95 | 'blue-500': 'rgba(49, 130, 206, 1)', 96 | 'blue-600': 'rgba(43, 108, 176, 1)', 97 | 'blue-700': 'rgba(44, 82, 130, 1)', 98 | 'blue-800': 'rgba(42, 67, 101, 1)', 99 | 'blue-900': 'rgba(26, 54, 93, 1)', 100 | 'purple-200': 'rgba(183, 148, 244, 1)', 101 | 'purple-300': 'rgba(159, 122, 234, 1)', 102 | }, 103 | backgroundImage: { 104 | 'green-gradient': 105 | 'linear-gradient(180deg, rgba(60, 139, 236, 0.2) 0%, rgba(168, 245, 220, 0.2) 100%)', 106 | 'blue-gradient': 107 | 'linear-gradient(123.77deg, rgba(197, 100, 237, 0.2) -2.01%, rgba(89, 212, 237, 0.2) 102.02%)', 108 | }, 109 | animation: { 110 | 'spin-slow': 'spin 3s linear infinite', 111 | }, 112 | }, 113 | }, 114 | variants: { 115 | extend: {}, 116 | }, 117 | plugins: [], 118 | }; 119 | -------------------------------------------------------------------------------- /tasks/deploy-calculator.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | 4 | const fs = require('fs/promises'); 5 | const path = require('node:path'); 6 | 7 | const { networks } = require('../networks'); 8 | 9 | task( 10 | 'deploy-calculator', 11 | 'Deploys the GitHub calculator smart contract.' 12 | ).setAction(async () => { 13 | try { 14 | const source = await fs.readFile( 15 | path.join(__dirname, '../functions/github-metric-times-ether.js'), 16 | { encoding: 'utf8' } 17 | ); 18 | console.log(source); 19 | 20 | /** @var {FunctionsConsumer} contract */ 21 | const factory = await ethers.getContractFactory('GitHubFunctions'); 22 | const contract = await factory.deploy( 23 | networks[network.name].functionsRouter, 24 | networks[network.name].donId, 25 | source 26 | ); 27 | await contract.deployTransaction.wait(1); 28 | 29 | console.log(`Deployed contract to ${contract.address}`); 30 | } catch (e) { 31 | console.log(e.message); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /tasks/index.js: -------------------------------------------------------------------------------- 1 | //exports.keepers = require('./automation') 2 | exports.FunctionsClient = require("./Functions-client") 3 | exports.FunctionsBilling = require("./Functions-billing") 4 | exports.accounts = require("./accounts") 5 | exports.balance = require("./balance") 6 | exports.blockNumber = require("./block-number") 7 | -------------------------------------------------------------------------------- /tasks/index.ts: -------------------------------------------------------------------------------- 1 | // import './index.js'; 2 | 3 | import './upgradeOrDeploy'; 4 | import './simulateScript'; 5 | -------------------------------------------------------------------------------- /tasks/simulateScript.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const { 6 | simulateScript, 7 | decodeResult, 8 | } = require('@chainlink/functions-toolkit'); 9 | const { 10 | Location, 11 | ReturnType, 12 | CodeLanguage, 13 | } = require('@chainlink/functions-toolkit'); 14 | 15 | task( 16 | 'functions-simulate-script', 17 | 'Executes the JavaScript source code locally' 18 | ).setAction(async () => { 19 | const checkScriptPath = path.resolve( 20 | __dirname, 21 | '../', 22 | 'functions', 23 | 'github-metric-times-ether.js' 24 | ); 25 | const checkScript = fs.readFileSync(checkScriptPath, 'utf8'); 26 | 27 | const requestConfig = { 28 | source: checkScript, 29 | args: [ 30 | 'https://github.com/smartcontractkit/chainlink-functions-demo-app', 31 | 'stars', 32 | '1', 33 | '1000000000', 34 | ], 35 | codeLanguage: CodeLanguage.JavaScript, 36 | expectedReturnType: ReturnType.bytes, 37 | codeLocation: Location.Inline, 38 | }; 39 | 40 | // Simulate the JavaScript execution locally 41 | const { responseBytesHexstring, errorString, capturedTerminalOutput } = 42 | await simulateScript(requestConfig); 43 | console.log(`${capturedTerminalOutput}\n`); 44 | if (responseBytesHexstring) { 45 | console.log( 46 | `Response returned by script during local simulation: ${decodeResult( 47 | responseBytesHexstring, 48 | requestConfig.expectedReturnType 49 | ).toString()}\n` 50 | ); 51 | } 52 | if (errorString) { 53 | console.log(`Error returned by simulated script:\n${errorString}\n`); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /tasks/upgradeOrDeploy.ts: -------------------------------------------------------------------------------- 1 | import { task } from 'hardhat/config'; 2 | import dotenv from 'dotenv'; 3 | dotenv.config(); 4 | 5 | import { networks } from '../networks'; 6 | import { Ledger } from '../typechain-types'; 7 | import path from 'node:path'; 8 | import fs from 'node:fs/promises'; 9 | 10 | task( 11 | 'project:deploy', 12 | 'Deploys or upgrades the latest version of the Ledger and Donation' 13 | ) 14 | .addOptionalParam('address', 'Where the current proxy is deployed') 15 | .setAction(async (taskArgs, { network, ethers, upgrades }) => { 16 | const proxyAddress = 17 | taskArgs.address || process.env.NEXT_PUBLIC_CONTRACT_ADDRESS; 18 | const factory = await ethers.getContractFactory('Ledger'); 19 | const functionsPath = path.resolve(__dirname, '../', 'functions'); 20 | const checkScriptPath = path.resolve( 21 | functionsPath, 22 | 'get-wallet-and-repos-from-gist.js' 23 | ); 24 | const calculateScriptPath = path.resolve( 25 | functionsPath, 26 | 'github-metric-times-ether.js' 27 | ); 28 | const checkScript = await fs.readFile(checkScriptPath, { 29 | encoding: 'utf-8', 30 | }); 31 | const calculateScript = await fs.readFile(calculateScriptPath, { 32 | encoding: 'utf-8', 33 | }); 34 | 35 | const donIdBytes32 = ethers.utils.formatBytes32String(networks?.[network.name].donId) 36 | 37 | if (!proxyAddress) { 38 | const ledger = await upgrades.deployProxy( 39 | factory, 40 | [ 41 | donIdBytes32, 42 | calculateScript, 43 | checkScript, 44 | ], 45 | { 46 | initializer: 'initialize', 47 | constructorArgs: [networks?.[network.name].functionsRouter], 48 | unsafeAllow: ['constructor', 'state-variable-immutable'], 49 | kind: 'uups', 50 | } 51 | ); 52 | await ledger.deployed(); 53 | 54 | console.log('Ledger deployed to:', ledger.address); 55 | } else { 56 | const ledger = await upgrades.upgradeProxy(proxyAddress, factory, { 57 | constructorArgs: [networks?.[network.name].functionsRouter], 58 | unsafeAllow: ['constructor'], 59 | kind: 'uups', 60 | }); 61 | const version = await (ledger as Ledger).getVersion(); 62 | 63 | console.log(`Ledger upgraded to version ${version} at ${proxyAddress}`); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "typeRoots": ["types.d.ts", "node_modules/@types"], 19 | "paths": { 20 | "@components/*": ["components/*"], 21 | "@utils/*": ["utils/*"], 22 | "@lib/*": ["lib/*"], 23 | "@services/*": ["services/*"], 24 | "@icons/*": ["public/icons/*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | type InjectedProviders = { 2 | isMetaMask?: true; 3 | }; 4 | 5 | interface Window { 6 | ethereum: InjectedProviders & { 7 | on: (...args: any[]) => void; 8 | removeListener?: (...args: any[]) => void; 9 | request(args: any): Promise; 10 | }; 11 | } 12 | 13 | type User = { 14 | email: string; 15 | imageUrl?: string; 16 | }; 17 | --------------------------------------------------------------------------------