├── .gitignore ├── webui ├── src │ ├── react-app-env.d.ts │ ├── utils │ │ ├── chaincfg.ts │ │ ├── polyfills.ts │ │ ├── reportWebVitals.ts │ │ └── ConvertHelpers.ts │ ├── setupTests.ts │ ├── components │ │ ├── vaultinfo │ │ │ ├── ClaimForm.css │ │ │ ├── VaultInfo.tsx │ │ │ ├── EligibilityCheck.tsx │ │ │ └── ClaimForm.tsx │ │ ├── grantlist │ │ │ ├── GrantList.css │ │ │ ├── GrantList.tsx │ │ │ └── GrantItem.tsx │ │ ├── VaultPage.tsx │ │ ├── grant_delete │ │ │ └── GrantDelete.tsx │ │ ├── background │ │ │ ├── EthLogoMesh.tsx │ │ │ └── Background.tsx │ │ ├── grant_lock │ │ │ └── GrantLock.tsx │ │ ├── grant_transfer │ │ │ └── GrantTransfer.tsx │ │ ├── grant_rename │ │ │ └── GrantRename.tsx │ │ ├── grant_update │ │ │ └── GrantUpdate.tsx │ │ └── grant_create │ │ │ └── GrantCreate.tsx │ ├── index.css │ ├── index.tsx │ ├── config.ts │ └── abi │ │ ├── VaultToken.json │ │ └── FundingVault.json ├── build.sh ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── adamsbridge.hdr │ ├── .well-known │ │ └── walletconnect.txt │ ├── manifest.json │ └── index.html ├── README.md ├── .gitignore ├── tsconfig.json └── package.json ├── fundingvault ├── audit │ └── NM-0234-Ethereum-Foundation-Final.pdf ├── contracts │ ├── IFundingVault.sol │ ├── IFundingVaultToken.sol │ ├── FundingVaultProxyStorage.sol │ ├── FundingVaultProxy.sol │ ├── FundingVaultToken.sol │ ├── ReentrancyGuard.sol │ └── FundingVaultV1.sol ├── package.json ├── hardhat.config.js ├── .gitignore ├── scripts │ └── bootstrap.js ├── contract-json │ └── FundingVaultProxy.json ├── docs │ └── TechnicalConcept.md └── test │ └── TestFundingVaultProxy.js ├── .github ├── dependabot.yml ├── workflows │ └── build-test.yaml └── ISSUE_TEMPLATE │ └── application.yaml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .vscode 3 | -------------------------------------------------------------------------------- /webui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /webui/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | yarn install 5 | npm run build 6 | -------------------------------------------------------------------------------- /webui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /webui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethpandaops/fundingvault/HEAD/webui/public/favicon.ico -------------------------------------------------------------------------------- /webui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethpandaops/fundingvault/HEAD/webui/public/logo192.png -------------------------------------------------------------------------------- /webui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethpandaops/fundingvault/HEAD/webui/public/logo512.png -------------------------------------------------------------------------------- /webui/public/adamsbridge.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethpandaops/fundingvault/HEAD/webui/public/adamsbridge.hdr -------------------------------------------------------------------------------- /webui/public/.well-known/walletconnect.txt: -------------------------------------------------------------------------------- 1 | 2b1c775b-d1a2-474e-b5b2-cbe10eef22a3=89323c6fb8721fc05b601e8946bae4d0ba1ac3e2cbfbdcc2df5b5b0a04bc6f72 -------------------------------------------------------------------------------- /fundingvault/audit/NM-0234-Ethereum-Foundation-Final.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethpandaops/fundingvault/HEAD/fundingvault/audit/NM-0234-Ethereum-Foundation-Final.pdf -------------------------------------------------------------------------------- /fundingvault/contracts/IFundingVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | interface IFundingVault { 5 | function notifyGrantTransfer(uint64 grantId) external; 6 | } 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | groups: 8 | actions: 9 | patterns: 10 | - '*' 11 | -------------------------------------------------------------------------------- /webui/src/utils/chaincfg.ts: -------------------------------------------------------------------------------- 1 | import CurrentConfig, { ChainConfig} from "../config"; 2 | 3 | export function ConfigForChainId(chainId: number): ChainConfig | undefined { 4 | return CurrentConfig.Chains.find((chainConfig) => chainConfig.Chain.id === chainId); 5 | } -------------------------------------------------------------------------------- /webui/src/utils/polyfills.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | 3 | window.global = window.global ?? window; 4 | window.Buffer = window.Buffer ?? Buffer; 5 | window.process = (window.process ?? { env: {} }) as any; // Minimal process polyfill 6 | 7 | export {}; 8 | -------------------------------------------------------------------------------- /webui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /webui/src/components/vaultinfo/ClaimForm.css: -------------------------------------------------------------------------------- 1 | 2 | .details-table .prop { 3 | width: 200px; 4 | } 5 | 6 | .claim-form { 7 | margin-top: 20px; 8 | } 9 | 10 | .claim-form .claim-button { 11 | width: 100%; 12 | } 13 | 14 | .claim-form .txhash { 15 | font-size: 0.8em; 16 | } -------------------------------------------------------------------------------- /fundingvault/contracts/IFundingVaultToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | import "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; 5 | 6 | interface IFundingVaultToken is IERC721Enumerable { 7 | function tokenUpdate(uint64 tokenId, address targetAddr) external; 8 | } 9 | -------------------------------------------------------------------------------- /webui/README.md: -------------------------------------------------------------------------------- 1 | # FundingVault Web UI 2 | 3 | This Web UI provides a simple interface to claim funds from the FundingVault on Holesky & Sepolia. 4 | 5 | ## Startup 6 | `npm run start` 7 | 8 | ## Thanks To 9 | 10 | Special thanks to [a16z/eth-testnet-drop](https://github.com/a16z/eth-testnet-drop) for the amazing background animation -------------------------------------------------------------------------------- /fundingvault/contracts/FundingVaultProxyStorage.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | contract FundingVaultProxyStorage { 5 | // slot 0x00 - manager address (admin) 6 | address internal _manager; 7 | uint96 internal __unused0; 8 | // slot 0x01 - implementation address 9 | address internal _implementation; 10 | uint96 internal __unused1; 11 | } 12 | -------------------------------------------------------------------------------- /webui/.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 | # production 12 | build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /fundingvault/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hardhat-project", 3 | "license": "MIT", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "build": "hardhat compile", 7 | "test": "hardhat test" 8 | }, 9 | "devDependencies": { 10 | "@nomicfoundation/hardhat-network-helpers": "^1.0.10", 11 | "@nomicfoundation/hardhat-toolbox": "^5.0.0", 12 | "@openzeppelin/contracts": "^4.9.5", 13 | "hardhat": "^2.22.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /webui/src/components/grantlist/GrantList.css: -------------------------------------------------------------------------------- 1 | 2 | .grants-page { 3 | max-width: 1400px; 4 | } 5 | 6 | .grant-edit-btn { 7 | margin-left: 8px; 8 | } 9 | 10 | .manager-limits .cooldown-bar { 11 | max-width: 700px; 12 | width: 70%; 13 | } 14 | 15 | .grants-page .create-btn { 16 | float: right; 17 | margin: 4px 0; 18 | } 19 | 20 | .grant-manager-dialog .field-label { 21 | display: inline-block; 22 | margin-top: 6px; 23 | } -------------------------------------------------------------------------------- /webui/src/utils/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /webui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "FundingVault Interface", 3 | "name": "Webinterface for FundingVault contract", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /webui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": false, 14 | "incremental": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "maxNodeModuleJsDepth": 1000, 22 | "noEmit": true, 23 | "jsx": "react-jsx" 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 20 | with: 21 | node-version: '18.17.1' 22 | 23 | - name: Install dependencies 24 | run: cd fundingvault && npm install 25 | 26 | - name: Build project 27 | run: cd fundingvault && npm run build 28 | 29 | - name: Run tests 30 | run: cd fundingvault && npm run test 31 | -------------------------------------------------------------------------------- /webui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | FundingVault Interface 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /webui/src/index.css: -------------------------------------------------------------------------------- 1 | 2 | #root { 3 | height: 100vh; 4 | } 5 | 6 | .foreground-container { 7 | position: absolute; 8 | margin: 0; 9 | padding: 0; 10 | height: 100%; 11 | width: 100%; 12 | 13 | top: 0; 14 | left: 0; 15 | 16 | pointer-events: all; 17 | 18 | overflow-y: auto; 19 | word-break: wrap; 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | 24 | .page-wrapper { 25 | text-align: center; 26 | flex-grow: 1; 27 | position: relative; 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | justify-content: center; 32 | } 33 | 34 | .page-header { 35 | display: flex; 36 | min-height: 64px; 37 | justify-content: flex-end; 38 | padding: 12px; 39 | } 40 | 41 | .page-footer { 42 | display: flex; 43 | min-height: 64px; 44 | justify-content: center; 45 | padding: 12px; 46 | } 47 | 48 | .page-block { 49 | background-color: rgba(240, 248, 255, .7); 50 | border-radius: .375rem; 51 | box-shadow: 0 0 #0000, 0 0 #0000, 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1); 52 | text-align: left; 53 | padding: 12px; 54 | width: 90%; 55 | max-width: 600px; 56 | min-height: 200px; 57 | } -------------------------------------------------------------------------------- /webui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import '@rainbow-me/rainbowkit/styles.css'; 2 | import './utils/polyfills'; 3 | import './index.css'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom/client'; 6 | import reportWebVitals from './utils/reportWebVitals'; 7 | import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit'; 8 | import { WagmiProvider } from 'wagmi'; 9 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 10 | 11 | import VaultPage from './components/VaultPage'; 12 | import { KnownChains } from './config'; 13 | 14 | const config = getDefaultConfig({ 15 | appName: 'FundingVault', 16 | projectId: '4b8923523ec77b9be8ab9fd4ff539b48', 17 | chains: KnownChains as any, 18 | }); 19 | 20 | const root = ReactDOM.createRoot( 21 | document.getElementById('root') as HTMLElement 22 | ); 23 | 24 | const queryClient = new QueryClient(); 25 | 26 | root.render( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | 38 | reportWebVitals(); 39 | -------------------------------------------------------------------------------- /fundingvault/hardhat.config.js: -------------------------------------------------------------------------------- 1 | require("@nomicfoundation/hardhat-toolbox"); 2 | require("@nomicfoundation/hardhat-ignition"); 3 | 4 | const DEPLOYER_PRIVATE_KEY = vars.has("FUNDINGVAULT_DEPLOYER_PRIVATE_KEY") ? [ vars.get("FUNDINGVAULT_DEPLOYER_PRIVATE_KEY") ] : undefined; 5 | 6 | /** @type import('hardhat/config').HardhatUserConfig */ 7 | module.exports = { 8 | paths: { 9 | sources: "./contracts", 10 | }, 11 | networks: { 12 | hardhat: { 13 | chainId: 1337, 14 | allowBlocksWithSameTimestamp: true, 15 | gas: 8000000, 16 | mining: { 17 | auto: true, 18 | interval: 0 19 | } 20 | }, 21 | sepolia: { 22 | chainId: 11155111, 23 | url: `https://rpc.sepolia.ethpandaops.io/`, 24 | accounts: DEPLOYER_PRIVATE_KEY, 25 | }, 26 | holesky: { 27 | chainId: 17000, 28 | url: `https://rpc.holesky.ethpandaops.io/`, 29 | accounts: DEPLOYER_PRIVATE_KEY, 30 | }, 31 | hoodi: { 32 | chainId: 560048, 33 | url: `https://rpc.hoodi.ethpandaops.io/`, 34 | accounts: DEPLOYER_PRIVATE_KEY, 35 | }, 36 | ephemery: { 37 | url: `https://otter.bordel.wtf/erigon`, 38 | accounts: DEPLOYER_PRIVATE_KEY, 39 | }, 40 | }, 41 | solidity: { 42 | version: "0.8.21", 43 | settings: { 44 | optimizer: { 45 | enabled: true, 46 | runs: 2000, 47 | }, 48 | }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /webui/src/components/vaultinfo/VaultInfo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | } from "wagmi"; 4 | import { 5 | useConnectModal, 6 | } from '@rainbow-me/rainbowkit'; 7 | import EligibilityCheck from "./EligibilityCheck"; 8 | 9 | 10 | const VaultInfo = (): React.ReactElement => { 11 | const { isConnected, chain } = useAccount(); 12 | const { openConnectModal } = useConnectModal(); 13 | 14 | return ( 15 |
16 |

Testnet Funding Vault

17 |

18 | The FundingVault contract provides a way to distribute continuous limited amounts of funds to authorized entities.
19 | The distribution is time gated and a specific limit per grant is enforced.
20 | Check out the FundingVault repository for more details. 21 |

22 | {isConnected && chain ? 23 | : null} 24 | {!isConnected ? renderDisconnected() : null} 25 | {isConnected && !chain ? renderInvalidNetwork() : null} 26 |
27 | ) 28 | 29 | function renderDisconnected() { 30 | return ( 31 |
32 | Please connect to your wallet to continue. 33 |
34 | ) 35 | } 36 | 37 | function renderInvalidNetwork() { 38 | return ( 39 |
40 | Please switch to holesky or sepolia to continue. 41 |
42 | ) 43 | } 44 | } 45 | 46 | export default VaultInfo; -------------------------------------------------------------------------------- /webui/src/components/vaultinfo/EligibilityCheck.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useReadContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import VaultTokenAbi from "../../abi/VaultToken.json"; 8 | import ClaimForm from "./ClaimForm"; 9 | 10 | const EligibilityCheck = (): React.ReactElement => { 11 | const { address, chain } = useAccount(); 12 | let chainConfig = ConfigForChainId(chain!.id)!; 13 | 14 | const tokenBalance = useReadContract({ 15 | address: chainConfig.TokenContractAddr, 16 | abi: VaultTokenAbi, 17 | chainId: chainConfig.Chain.id, 18 | functionName: "balanceOf", 19 | args: [ address ], 20 | }); 21 | const firstTokenId = useReadContract({ 22 | address: chainConfig.TokenContractAddr, 23 | abi: VaultTokenAbi, 24 | chainId: chainConfig.Chain.id, 25 | functionName: "tokenOfOwnerByIndex", 26 | args: [ address, 0 ], 27 | }); 28 | 29 | if(tokenBalance.isLoading || firstTokenId.isLoading) { 30 | return ( 31 | 32 | 33 | ) 34 | } 35 | if(tokenBalance.isError) { 36 | return ( 37 |
Failed checking eligibility: {tokenBalance.error.message}
38 | ) 39 | } 40 | if(tokenBalance.data == 0) { 41 | return ( 42 |
43 | Sorry, your wallet ({address}) is not authorized to request funds from the FundingVault. Have you applied for a grant already? 44 |
45 | ) 46 | } 47 | if(firstTokenId.isError) { 48 | return ( 49 |
Failed checking eligibility: {firstTokenId.error.message}
50 | ) 51 | } 52 | 53 | return ; 54 | } 55 | 56 | const Loading = (props: {text: string}) => { 57 | return ( 58 |
59 | {props.text} 60 |
61 | ) 62 | } 63 | 64 | export default EligibilityCheck; 65 | -------------------------------------------------------------------------------- /webui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fundingvault/webui", 3 | "version": "0.0.1", 4 | "description": "Static web frontend for FundingVault contract", 5 | "author": "pk910", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@rainbow-me/rainbowkit": "^2.0.4", 9 | "@rainbow-me/rainbowkit-siwe-next-auth": "^0.4.0", 10 | "@react-three/cannon": "^6.6.0", 11 | "@react-three/drei": "^9.105.1", 12 | "@react-three/fiber": "^8.16.1", 13 | "@tanstack/react-query": "^5.28.4", 14 | "@testing-library/jest-dom": "^6.2.0", 15 | "@testing-library/react": "^14.1.2", 16 | "@testing-library/user-event": "^14.5.2", 17 | "@types/jest": "^29.5.2", 18 | "@types/node": "^18.19.3", 19 | "@types/react": "^18.2.64", 20 | "@types/three": "^0.163.0", 21 | "buffer": "npm:buffer@6.0.3", 22 | "ethers": "^6.11.1", 23 | "react": "^18.2.0", 24 | "react-bootstrap": "^2.10.2", 25 | "react-dom": "^18.2.0", 26 | "react-router-dom": "^6.22.3", 27 | "react-scripts": "5.0.1", 28 | "three": "^0.163.0", 29 | "typescript": "5.4.2", 30 | "util": "0.12.4", 31 | "viem": "^2.8.12", 32 | "wagmi": "^2.5.11", 33 | "web-vitals": "^2.1.4" 34 | }, 35 | "scripts": { 36 | "dev": "react-scripts start", 37 | "start": "react-scripts start", 38 | "build": "REACT_APP_GIT_VERSION=$(git rev-parse --short HEAD) react-scripts build", 39 | "test": "react-scripts test --transformIgnorePatterns \"node_modules/(?!@rainbow-me)/\"", 40 | "eject": "react-scripts eject" 41 | }, 42 | "eslintConfig": { 43 | "extends": [ 44 | "react-app", 45 | "react-app/jest" 46 | ], 47 | "rules": { 48 | "eqeqeq": "off", 49 | "jsx-a11y/anchor-is-valid": "off" 50 | } 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not ie <= 99", 56 | "not android <= 4.4.4", 57 | "not dead", 58 | "not op_mini all" 59 | ], 60 | "development": [ 61 | "last 1 chrome version", 62 | "last 1 firefox version", 63 | "last 1 safari version" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /fundingvault/contracts/FundingVaultProxy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | /* 5 | ################################################################## 6 | # Holešovice Funding Vault # 7 | # # 8 | # This contract is used to distribute fund reserves to faucets # 9 | # or other projects that have a ongoing need for testnet funds. # 10 | # # 11 | # see https://dev.pk910.de/ethvault by pk910.eth # 12 | ################################################################## 13 | */ 14 | 15 | import "@openzeppelin/contracts/utils/Address.sol"; 16 | import "./FundingVaultProxyStorage.sol"; 17 | 18 | 19 | contract FundingVaultProxy is FundingVaultProxyStorage { 20 | 21 | constructor() { 22 | _manager = msg.sender; 23 | } 24 | 25 | modifier ifManager() { 26 | if (msg.sender == _manager) { 27 | _; 28 | } else { 29 | _fallback(); 30 | } 31 | } 32 | 33 | function _fallback() internal { 34 | require(_implementation != address(0), "no implementation"); 35 | _delegate(_implementation); 36 | } 37 | 38 | function _delegate(address impl) internal virtual { 39 | assembly { 40 | calldatacopy(0, 0, calldatasize()) 41 | let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) 42 | returndatacopy(0, 0, returndatasize()) 43 | switch result 44 | case 0 { 45 | revert(0, returndatasize()) 46 | } 47 | default { 48 | return(0, returndatasize()) 49 | } 50 | } 51 | } 52 | 53 | fallback() external payable { 54 | _fallback(); 55 | } 56 | 57 | receive() external payable { 58 | _fallback(); 59 | } 60 | 61 | function implementation() public view returns (address) { 62 | return _implementation; 63 | } 64 | 65 | function upgradeTo(address addr) external ifManager { 66 | _implementation = addr; 67 | } 68 | 69 | function upgradeToAndCall(address addr, bytes calldata data) external ifManager { 70 | _implementation = addr; 71 | Address.functionDelegateCall(addr, data); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /fundingvault/.gitignore: -------------------------------------------------------------------------------- 1 | # Allow a dummy project in this directory for testing purposes 2 | myproject 3 | 4 | /node_modules 5 | /.idea 6 | *.tsbuildinfo 7 | 8 | # VS Code workspace config 9 | workspace.code-workspace 10 | 11 | .DS_Store 12 | 13 | # Below is Github's node gitignore template, 14 | # ignoring the node_modules part, as it'd ignore every node_modules, and we have some for testing 15 | 16 | # Logs 17 | logs 18 | *.log 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | node_modules/ 52 | jspm_packages/ 53 | 54 | # TypeScript v1 declaration files 55 | typings/ 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional REPL history 61 | .node_repl_history 62 | 63 | # Output of 'pnpm pack' 64 | *.tgz 65 | 66 | # parcel-bundler cache (https://parceljs.org/) 67 | .cache 68 | 69 | # next.js build output 70 | .next 71 | 72 | # nuxt.js build output 73 | .nuxt 74 | 75 | # vuepress build output 76 | .vuepress/dist 77 | 78 | # Serverless directories 79 | .serverless/ 80 | 81 | # FuseBox cache 82 | .fusebox/ 83 | 84 | # DynamoDB Local files 85 | .dynamodb/ 86 | 87 | 88 | docs/.env.example 89 | 90 | # Generated by Cargo 91 | # will have compiled files and executables 92 | /target/ 93 | 94 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 95 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 96 | Cargo.lock 97 | 98 | # These are backup files generated by rustfmt 99 | **/*.rs.bk 100 | 101 | # MSVC Windows builds of rustc generate these, which store debugging information 102 | *.pdb 103 | 104 | # VSCode settings 105 | .vscode/ 106 | *.code-workspace 107 | 108 | artifacts/ 109 | cache/ 110 | -------------------------------------------------------------------------------- /webui/src/components/VaultPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useAccount, useReadContract } from 'wagmi'; 3 | import { ConnectButton } from '@rainbow-me/rainbowkit'; 4 | import { Suspense } from "react"; 5 | import { BrowserRouter, Route, Routes, Link } from "react-router-dom"; 6 | 7 | import CurrentConfig from "../config"; 8 | import { ConfigForChainId } from "../utils/chaincfg"; 9 | import FundingVaultAbi from "../abi/FundingVault.json"; 10 | 11 | import Background from "./background/Background"; 12 | import VaultInfo from './vaultinfo/VaultInfo'; 13 | import GrantList from "./grantlist/GrantList"; 14 | 15 | const VaultPage = (): React.ReactElement => { 16 | const { address: walletAddress, isConnected, chain } = useAccount(); 17 | 18 | let chainConfig = ConfigForChainId(chain?.id)!; 19 | const managerCheck = useReadContract(chain ? { 20 | address: chainConfig.VaultContractAddr, 21 | account: walletAddress, 22 | abi: FundingVaultAbi, 23 | chainId: chainConfig.Chain.id, 24 | functionName: "hasRole", 25 | args: [ CurrentConfig.ManagerRole, walletAddress ], 26 | }: undefined); 27 | 28 | var isManager = isConnected && chain && managerCheck.isSuccess && managerCheck.data; 29 | 30 | return ( 31 | 32 | 33 | 34 |
35 |
36 | {isManager ? 37 |
38 | 39 | 40 | 41 |
42 | : null} 43 | 44 |
45 |
46 | 47 | 49 | )} /> 50 | 52 | ) : null} /> 53 | 54 |
55 |
56 | Powered by ethpandaops/fundingvault | {CurrentConfig.AppVersion ? "git-" + CurrentConfig.AppVersion : "dev build"} 57 |
58 |
59 |
60 |
61 |
62 | ) 63 | } 64 | 65 | export default VaultPage; -------------------------------------------------------------------------------- /fundingvault/contracts/FundingVaultToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | /* 5 | ################################################################## 6 | # Holešovice Funding Vault # 7 | # # 8 | # This contract is used to distribute fund reserves to faucets # 9 | # or other projects that have a ongoing need for testnet funds. # 10 | # # 11 | # Vault contract: 0x610866c6089768dA95524bcc4cE7dB61eDa3931c # 12 | # # 13 | # see https://dev.pk910.de/ethvault by pk910.eth # 14 | ################################################################## 15 | */ 16 | 17 | import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; 18 | import "./IFundingVaultToken.sol"; 19 | import "./IFundingVault.sol"; 20 | 21 | contract FundingVaultToken is ERC721Enumerable, IFundingVaultToken { 22 | address private _fundingVault; 23 | 24 | constructor(address fundingVault) ERC721("FundingVault Grant", "Funding Grant") { 25 | _fundingVault = fundingVault; 26 | } 27 | 28 | receive() external payable { 29 | if(msg.value > 0) { 30 | (bool sent, ) = payable(_fundingVault).call{value: msg.value}(""); 31 | require(sent, "failed to forward ether"); 32 | } 33 | } 34 | 35 | function getVault() public view returns (address) { 36 | return _fundingVault; 37 | } 38 | 39 | function _baseURI() internal view override returns (string memory) { 40 | return string(abi.encodePacked("https://dev.pk910.de/ethvault?c=", Strings.toString(block.chainid), "&v=", Strings.toHexString(uint160(_fundingVault), 20), "&p=")); 41 | } 42 | 43 | function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize) internal virtual override { 44 | super._beforeTokenTransfer(from, to, tokenId, batchSize); 45 | 46 | IFundingVault(_fundingVault).notifyGrantTransfer(uint64(tokenId)); 47 | } 48 | 49 | function tokenUpdate(uint64 tokenId, address targetAddr) public { 50 | require(_msgSender() == _fundingVault, "not vault contract"); 51 | 52 | if(targetAddr != address(0)) { 53 | if(!_exists(tokenId)) { 54 | _safeMint(targetAddr, tokenId); 55 | } 56 | else if(_ownerOf(tokenId) != targetAddr) { 57 | _safeTransfer(_ownerOf(tokenId), targetAddr, tokenId, ""); 58 | } 59 | } 60 | else if(_exists(tokenId)) { 61 | _burn(tokenId); 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /webui/src/utils/ConvertHelpers.ts: -------------------------------------------------------------------------------- 1 | 2 | export function base64ToHex(str: string) { 3 | const raw = atob(str); 4 | let result = ''; 5 | for (let i = 0; i < raw.length; i++) { 6 | const hex = raw.charCodeAt(i).toString(16); 7 | result += (hex.length === 2 ? hex : '0' + hex); 8 | } 9 | return result; 10 | } 11 | 12 | export function toDecimalUnit(amount: number | bigint, decimals?: number): number { 13 | let factor = Math.pow(10, decimals || 0); 14 | if(typeof amount === "bigint") 15 | amount = Number(amount); 16 | return amount / factor; 17 | } 18 | 19 | export function toBigintUnit(amount: number | bigint, decimals?: number): bigint { 20 | let factor = Math.pow(10, decimals || 0); 21 | if(typeof amount === "bigint") 22 | amount = Number(amount); 23 | return BigInt(amount * factor); 24 | } 25 | 26 | export function toReadableAmount(amount: number | bigint, decimals?: number, unit?: string, precision?: number): string { 27 | if(typeof decimals !== "number") 28 | decimals = 18; 29 | if(!precision) 30 | precision = 3; 31 | if(!amount) 32 | return "0"+ (unit ? " " + unit : ""); 33 | if(typeof amount === "bigint") 34 | amount = Number(amount); 35 | 36 | let decimalAmount = toDecimalUnit(amount, decimals); 37 | let precisionFactor = Math.pow(10, precision); 38 | let amountStr = (Math.round(decimalAmount * precisionFactor) / precisionFactor).toString(); 39 | 40 | return amountStr + (unit ? " " + unit : ""); 41 | } 42 | 43 | export function toReadableDuration(duration: number | bigint, maxParts?: number): string { 44 | if(typeof duration === "bigint") 45 | duration = Number(duration); 46 | if(typeof maxParts != "number") 47 | maxParts = 0; 48 | let res = ""; 49 | let factor; 50 | let parts = 0; 51 | 52 | factor = (60 * 60 * 24 * 30); 53 | if(duration >= factor && (maxParts == 0 || parts < maxParts)) { 54 | let val = Math.floor(duration / factor); 55 | duration -= val * factor; 56 | res += (res ? " " : "") + val + "M"; 57 | parts++; 58 | } 59 | 60 | factor = (60 * 60 * 24); 61 | if(duration >= factor && (maxParts == 0 || parts < maxParts)) { 62 | let val = Math.floor(duration / factor); 63 | duration -= val * factor; 64 | res += (res ? " " : "") + val + "d"; 65 | parts++; 66 | } 67 | 68 | factor = (60 * 60); 69 | if(duration >= factor && (maxParts == 0 || parts < maxParts)) { 70 | let val = Math.floor(duration / factor); 71 | duration -= val * factor; 72 | res += (res ? " " : "") + val + "h"; 73 | parts++; 74 | } 75 | 76 | factor = (60); 77 | if(duration >= factor && (maxParts == 0 || parts < maxParts)) { 78 | let val = Math.floor(duration / factor); 79 | duration -= val * factor; 80 | res += (res ? " " : "") + val + "min"; 81 | parts++; 82 | } 83 | 84 | factor = 1; 85 | if(duration >= factor && (maxParts == 0 || parts < maxParts)) { 86 | let val = Math.floor(duration / factor); 87 | duration -= val * factor; 88 | res += (res ? " " : "") + val + "sec"; 89 | parts++; 90 | } 91 | 92 | return res; 93 | } 94 | 95 | -------------------------------------------------------------------------------- /fundingvault/scripts/bootstrap.js: -------------------------------------------------------------------------------- 1 | const { ethers, network } = require("hardhat"); 2 | const { vars } = require("hardhat/config"); 3 | const fs = require('fs'); 4 | 5 | 6 | /* bootstrap commands: 7 | npx hardhat vars set FUNDINGVAULT_DEPLOYER_PRIVATE_KEY 8 | npx hardhat vars set FUNDINGVAULT_OWNER_ADDRESS 9 | npx hardhat run scripts/bootstrap.js --network ephemery 10 | */ 11 | 12 | async function main() { 13 | const [deployer] = await ethers.getSigners(); 14 | console.log("deployer address (owner): " + deployer.address) 15 | console.log("") 16 | 17 | // First deploy proxy 18 | console.log("deploying FundingVaultProxy...") 19 | let Proxy = await ethers.getContractFactory("FundingVaultProxy"); 20 | let proxy = await Proxy.connect(deployer).deploy(); 21 | await proxy.waitForDeployment(); 22 | let proxyAddress = await proxy.getAddress(); 23 | console.log(" success: " + proxyAddress); 24 | 25 | // Then deploy token 26 | console.log("deploying FundingVaultToken...") 27 | let FundingVaultToken = await ethers.getContractFactory("FundingVaultToken"); 28 | let token = await FundingVaultToken.deploy(proxyAddress); 29 | await token.waitForDeployment(); 30 | let tokenAddress = await token.getAddress(); 31 | console.log(" success: " + tokenAddress); 32 | 33 | // Lastly, deploy vault implementation 34 | console.log("deploying FundingVaultV1...") 35 | let FundingVault = await ethers.getContractFactory("FundingVaultV1"); 36 | let vault = await FundingVault.connect(deployer).deploy(); 37 | await vault.waitForDeployment(); 38 | let vaultAddress = await vault.getAddress(); 39 | console.log(" success: " + vaultAddress); 40 | 41 | // Create instance of vault implementation, attached to the proxy contract 42 | let proxiedVault = FundingVault.attach(proxyAddress); 43 | 44 | // Initialize vault thorugh proxy's upgradeToAndCall 45 | console.log("calling upgradeToAndCall on FundingVaultProxy...") 46 | const initData = vault.interface.encodeFunctionData("initialize(address)", [tokenAddress]); 47 | console.log("init data: " + initData) 48 | await proxy.connect(deployer).upgradeToAndCall(vaultAddress, initData, { 49 | gasLimit: 150000, 50 | }); 51 | console.log(" success."); 52 | 53 | // change owner if set 54 | if(vars.has("FUNDINGVAULT_OWNER_ADDRESS")) { 55 | let ownerAddress = vars.get("FUNDINGVAULT_OWNER_ADDRESS"); 56 | let adminRole = "0x0000000000000000000000000000000000000000000000000000000000000000"; 57 | console.log("changing FundingVault admin to: " + ownerAddress); 58 | 59 | console.log("calling grantRole & setProxyManager on FundingVault..."); 60 | await proxiedVault.connect(deployer).grantRole(adminRole, ownerAddress, { 61 | gasLimit: 60000, 62 | }); 63 | await proxiedVault.connect(deployer).setProxyManager(ownerAddress, { 64 | gasLimit: 40000, 65 | }); 66 | console.log(" success."); 67 | 68 | console.log("calling revokeRole on FundingVault..."); 69 | await proxiedVault.connect(deployer).revokeRole(adminRole, deployer.address, { 70 | gasLimit: 60000, 71 | }); 72 | console.log(" success."); 73 | } 74 | 75 | } 76 | 77 | main() 78 | .then(() => process.exit(0)) 79 | .catch(error => { 80 | console.error(error); 81 | process.exit(1); 82 | }); -------------------------------------------------------------------------------- /fundingvault/contracts/ReentrancyGuard.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // OpenZeppelin Contracts v4.4.1 (security/ReentrancyGuard.sol) 3 | 4 | pragma solidity ^0.8.0; 5 | 6 | /** 7 | * @dev Contract module that helps prevent reentrant calls to a function. 8 | * 9 | * Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier 10 | * available, which can be applied to functions to make sure there are no nested 11 | * (reentrant) calls to them. 12 | * 13 | * Note that because there is a single `nonReentrant` guard, functions marked as 14 | * `nonReentrant` may not call one another. This can be worked around by making 15 | * those functions `private`, and then adding `external` `nonReentrant` entry 16 | * points to them. 17 | * 18 | * TIP: If you would like to learn more about reentrancy and alternative ways 19 | * to protect against it, check out our blog post 20 | * https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul]. 21 | */ 22 | abstract contract ReentrancyGuard { 23 | // Booleans are more expensive than uint256 or any type that takes up a full 24 | // word because each write operation emits an extra SLOAD to first read the 25 | // slot's contents, replace the bits taken up by the boolean, and then write 26 | // back. This is the compiler's defense against contract upgrades and 27 | // pointer aliasing, and it cannot be disabled. 28 | 29 | // The values being non-zero value makes deployment a bit more expensive, 30 | // but in exchange the refund on every call to nonReentrant will be lower in 31 | // amount. Since refunds are capped to a percentage of the total 32 | // transaction's gas, it is best to keep them low in cases like this one, to 33 | // increase the likelihood of the full refund coming into effect. 34 | uint256 private constant _NOT_ENTERED = 1; 35 | uint256 private constant _ENTERED = 2; 36 | 37 | uint256 internal _reentrancyStatus; 38 | 39 | /** 40 | * @dev Prevents a contract from calling itself, directly or indirectly. 41 | * Calling a `nonReentrant` function from another `nonReentrant` 42 | * function is not supported. It is possible to prevent this from happening 43 | * by making the `nonReentrant` function external, and making it call a 44 | * `private` function that does the actual work. 45 | */ 46 | modifier nonReentrant() { 47 | _nonReentrantBefore(); 48 | _; 49 | _nonReentrantAfter(); 50 | } 51 | 52 | function _nonReentrantBefore() private { 53 | // On the first call to nonReentrant, _reentrancyStatus will be _NOT_ENTERED 54 | require(_reentrancyStatus != _ENTERED, "ReentrancyGuard: reentrant call"); 55 | 56 | // Any calls to nonReentrant after this point will fail 57 | _reentrancyStatus = _ENTERED; 58 | } 59 | 60 | function _nonReentrantAfter() private { 61 | // By storing the original value once again, a refund is triggered (see 62 | // https://eips.ethereum.org/EIPS/eip-2200) 63 | _reentrancyStatus = _NOT_ENTERED; 64 | } 65 | 66 | /** 67 | * @dev Returns true if the reentrancy guard is currently set to "entered", which indicates there is a 68 | * `nonReentrant` function in the call stack. 69 | */ 70 | function _reentrancyGuardEntered() internal view returns (bool) { 71 | return _reentrancyStatus == _ENTERED; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /webui/src/components/grant_delete/GrantDelete.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useWriteContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import { Modal } from 'react-bootstrap'; 9 | 10 | const GrantDelete = (props: { grantId: number, name: string, owner: string, closeFn?: () => void }): React.ReactElement => { 11 | const { address, chain } = useAccount(); 12 | let chainConfig = ConfigForChainId(chain!.id)!; 13 | 14 | const deleteRequest = useWriteContract(); 15 | 16 | return ( 17 | { 18 | if(props.closeFn) 19 | props.closeFn(); 20 | }}> 21 | 22 | 23 | Delete Grant 24 | 25 | 26 | 27 |
28 |
29 |
30 | Grant ID: 31 |
32 |
33 | {props.grantId} ({props.name}) 34 |
35 |
36 |
37 |
38 | Owner: 39 |
40 |
41 | {props.owner} 42 |
43 |
44 | 45 | {deleteRequest.isPending && deleteRequest.data as any ? 46 |
47 |
48 |
49 | Delete transaction pending... TX: {deleteRequest.data} 50 |
51 |
52 |
53 | : null} 54 | {deleteRequest.isError ? 55 |
56 |
57 |
58 | Delete failed. {deleteRequest.data as any ? TX: {deleteRequest.data} : null}
59 | {deleteRequest.error.message} 60 |
61 |
62 |
63 | : null} 64 | {deleteRequest.isSuccess ? 65 |
66 |
67 |
68 | Delete TX: {deleteRequest.data} 69 |
70 |
71 |
72 | : null} 73 |
74 |
75 | 76 | 79 | 85 | 86 |
87 | ); 88 | 89 | function removeGrant(button: HTMLButtonElement) { 90 | button.disabled = true; 91 | 92 | deleteRequest.writeContract({ 93 | address: chainConfig.VaultContractAddr, 94 | account: address, 95 | abi: FundingVaultAbi, 96 | chainId: chainConfig.Chain.id, 97 | functionName: "removeGrant", 98 | args: [ props.grantId ], 99 | onSuccess: () => { 100 | }, 101 | }) 102 | } 103 | 104 | } 105 | 106 | export default GrantDelete; -------------------------------------------------------------------------------- /webui/src/components/background/EthLogoMesh.tsx: -------------------------------------------------------------------------------- 1 | import { useFrame } from "@react-three/fiber"; 2 | import { useRef } from "react"; 3 | import { Mesh, Vector3, Triangle, BufferGeometry, BufferAttribute, MeshStandardMaterial, DoubleSide } from "three"; 4 | 5 | let A = 0.35; 6 | let B = 0.5; 7 | let C = 0.65; 8 | let D = .55; 9 | let E = 0.7; 10 | let F = 0.95; 11 | let W = .35; 12 | let ZEPTH = .2; 13 | const vertices = [ 14 | // A 15 | -W, D, 0, 16 | 0, F, 0, 17 | 0, E, ZEPTH, 18 | 19 | // C 20 | -W, B, 0, 21 | 0, C, 0, 22 | 0, A, ZEPTH, 23 | 24 | // E 25 | -W, B, 0, 26 | 0, A, ZEPTH, 27 | 0, 0, 0, 28 | 29 | // Reflect across X 30 | // A 31 | W, D, 0, 32 | 0, F, 0, 33 | 0, E, ZEPTH, 34 | 35 | // C 36 | W, B, 0, 37 | 0, C, 0, 38 | 0, A, ZEPTH, 39 | 40 | // E 41 | W, B, 0, 42 | 0, A, ZEPTH, 43 | 0, 0, 0, 44 | 45 | // Reflect across Z 46 | // A 47 | W, D, 0, 48 | 0, F, 0, 49 | 0, E, -ZEPTH, 50 | 51 | // C 52 | -W, B, 0, 53 | 0, C, 0, 54 | 0, A, -ZEPTH, 55 | 56 | // E 57 | -W, B, 0, 58 | 0, A, -ZEPTH, 59 | 0, 0, 0, 60 | 61 | // Reflect across X 62 | // A 63 | -W, D, 0, 64 | 0, F, 0, 65 | 0, E, -ZEPTH, 66 | 67 | // C 68 | W, B, 0, 69 | 0, C, 0, 70 | 0, A, -ZEPTH, 71 | 72 | // E 73 | W, B, 0, 74 | 0, A, -ZEPTH, 75 | 0, 0, 0, 76 | 77 | // Bottoms 78 | 0, E, -ZEPTH, 79 | -W, D, 0, 80 | 0, E, ZEPTH, 81 | 82 | 0, E, -ZEPTH, 83 | W, D, 0, 84 | 0, E, ZEPTH, 85 | 86 | ]; 87 | 88 | function getNormal(points: number[]): number[] { 89 | let triangle = new Triangle( 90 | new Vector3(points[0], points[1], points[2]), 91 | new Vector3(points[3], points[4], points[5]), 92 | new Vector3(points[6], points[7], points[8]), 93 | ) 94 | let targetVector = new Vector3(); 95 | triangle.getNormal(targetVector); 96 | 97 | let flip = false 98 | for (let point of points) { 99 | if (point < 0) { 100 | flip = true; 101 | } 102 | } 103 | 104 | let asArr = flip ? [-targetVector.x, -targetVector.y, -targetVector.z] : [targetVector.x, targetVector.y, targetVector.z]; 105 | return [...asArr, ...asArr, ...asArr]; 106 | } 107 | 108 | function normalizeYValues(points: number[]): number[] { 109 | let max = 0; 110 | for (let i = 1; i <= points.length; i += 3) { 111 | if (points[i] > max) { 112 | max = points[i]; 113 | } 114 | } 115 | let diff = max / 2; 116 | for (let i = 1; i <= points.length; i += 3) { 117 | points[i] -= diff; 118 | } 119 | 120 | return points; 121 | } 122 | 123 | export default function EthLogoMesh() { 124 | let geometry = EthLogoGeometry(); 125 | const material = new MeshStandardMaterial( { color: "gray", side: DoubleSide } ); 126 | 127 | let boxRef = useRef(null!); 128 | 129 | useFrame(() => { 130 | boxRef.current.rotation.x += 0.005; 131 | boxRef.current.rotation.y += 0.01; 132 | }) 133 | 134 | return () 135 | } 136 | 137 | export function EthLogoGeometry(scale: number = 1): BufferGeometry { 138 | let geometry = new BufferGeometry(); 139 | let normalizedVerts = normalizeYValues(vertices) 140 | let mappedVerts = normalizedVerts.map(vert => vert *= scale); 141 | 142 | // Create normals 143 | const chunkSize = 9; 144 | let allNormals: number[] = [] 145 | for (let i = 0; i < mappedVerts.length; i += chunkSize) { 146 | const chunk = mappedVerts.slice(i, i + chunkSize); 147 | allNormals.push(...getNormal(chunk)); 148 | } 149 | 150 | geometry.setAttribute( 'position', new BufferAttribute( new Float32Array(mappedVerts), 3)); 151 | geometry.setAttribute( 'normal', new BufferAttribute( new Float32Array(allNormals), 3)) 152 | geometry.computeVertexNormals(); 153 | geometry.normalizeNormals() 154 | 155 | return geometry; 156 | } -------------------------------------------------------------------------------- /webui/src/components/grant_lock/GrantLock.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useWriteContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import { useState } from "react"; 9 | import { Modal } from 'react-bootstrap'; 10 | 11 | const GrantLock = (props: { grantId: number, name: string, closeFn?: () => void }): React.ReactElement => { 12 | const { address, chain } = useAccount(); 13 | let chainConfig = ConfigForChainId(chain!.id)!; 14 | let [timeInput, setTimeInput] = useState(0); 15 | 16 | const lockRequest = useWriteContract(); 17 | 18 | return ( 19 | { 20 | if(props.closeFn) 21 | props.closeFn(); 22 | }}> 23 | 24 | 25 | Lock Grant 26 | 27 | 28 | 29 |
30 |
31 |
32 | Grant ID: 33 |
34 |
35 | {props.grantId} ({props.name}) 36 |
37 |
38 |
39 |
40 | Lock Time: 41 |
42 |
43 | setTimeInput(parseInt(evt.target.value))} value={timeInput} /> 44 |
45 |
46 | 47 | {lockRequest.isPending && lockRequest.data as any ? 48 |
49 |
50 |
51 | Lock transaction pending... TX: {lockRequest.data} 52 |
53 |
54 |
55 | : null} 56 | {lockRequest.isError ? 57 |
58 |
59 |
60 | Lock failed. {lockRequest.data as any ? TX: {lockRequest.data} : null}
61 | {lockRequest.error.message} 62 |
63 |
64 |
65 | : null} 66 | {lockRequest.isSuccess ? 67 |
68 |
69 |
70 | Lock TX: {lockRequest.data} 71 |
72 |
73 |
74 | : null} 75 |
76 |
77 | 78 | 81 | 87 | 88 |
89 | ); 90 | 91 | function lockGrant(button: HTMLButtonElement) { 92 | button.disabled = true; 93 | 94 | lockRequest.writeContract({ 95 | address: chainConfig.VaultContractAddr, 96 | account: address, 97 | abi: FundingVaultAbi, 98 | chainId: chainConfig.Chain.id, 99 | functionName: "lockGrant", 100 | args: [ props.grantId, timeInput ], 101 | onSuccess: () => {}, 102 | }) 103 | } 104 | 105 | } 106 | 107 | export default GrantLock; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/application.yaml: -------------------------------------------------------------------------------- 1 | name: Funding Request 2 | description: Apply for a testnet funding 3 | title: "Funding Request: " 4 | labels: ["application"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out the funding request form! 10 | 11 | Plese note that this funding request form is not intended for normal developers or small stakers.\ 12 | There are lots of faucets available that can properly cover your needs: [https://faucetlink.to/](https://faucetlink.to/) 13 | 14 | If you're running a project that has an actual need for more funds, go ahead :) 15 | - type: input 16 | id: projectname 17 | attributes: 18 | label: Project Name 19 | description: The name of your project that needs a funding 20 | validations: 21 | required: true 22 | - type: input 23 | id: link 24 | attributes: 25 | label: Whats the link to your project? 26 | description: Enter a link to your projects website or github profile. 27 | placeholder: https://... 28 | validations: 29 | required: true 30 | - type: markdown 31 | attributes: 32 | value: | 33 | <b>Funding Methods</b>\ 34 | There are two possible funding methods: 35 | * Ongoing Funding: \ 36 | Allowance to request the granted amount of funds from a contract whenever needed. 37 | * One-Time Drop: \ 38 | A One-Time request for some amount of funds. 39 | 40 | - type: dropdown 41 | id: interval 42 | attributes: 43 | label: Is this a one-time request or do you need an ongoing funding? 44 | options: 45 | - Please Select 46 | - Ongoing Funding (monthly) 47 | - One-Time Drop 48 | validations: 49 | required: true 50 | - type: input 51 | id: amount 52 | attributes: 53 | label: What amount of funds do you need? 54 | description: | 55 | The desired amount per month / total amount for one-time grant. 56 | max. 100k for one-time drop, max. 50k per month for ongoing funding. 57 | You should specify the amount of funds you actually need and not just ask for the max possible amount. 58 | validations: 59 | required: true 60 | 61 | - type: textarea 62 | id: usage 63 | attributes: 64 | label: What do you need these funds for? 65 | description: | 66 | Tell us about your project and what you need the requested funds for. (min 50 words) 67 | Just deploying or testing a few contracts is not a valid reason, as the existing faucets are probably sufficient to cover your needs. 68 | validations: 69 | required: true 70 | 71 | - type: dropdown 72 | id: network 73 | attributes: 74 | label: Which testnet do you need funds on? 75 | options: 76 | - Please Select 77 | - Sepolia 78 | - Holesky 79 | - Hoodi 80 | validations: 81 | required: true 82 | - type: input 83 | id: wallet 84 | attributes: 85 | label: Wallet Address 86 | description: The address of the wallet the funds should go to. 87 | placeholder: 0x... 88 | validations: 89 | required: true 90 | 91 | - type: markdown 92 | attributes: 93 | value: | 94 | <b>Legit Check</b>\ 95 | In order to verify the legitimacy of your request we require you to provide one of the following: 96 | * DNS TXT Record: \ 97 | Add a TXT Record to your projects domain, containing the wallet address entered above. 98 | * Http accessible text file: \ 99 | Add a text file containing the wallet address entered above to your website and provide a link under your project domain for verification 100 | 101 | Please also try to submit the application from a github account that is clearly linked to your project. 102 | - type: input 103 | id: legitcheck 104 | attributes: 105 | label: Verification 106 | description: Please provide how we can verify the legitimacy of your request (domain with txt record / link to text file) 107 | validations: 108 | required: true 109 | 110 | - type: checkboxes 111 | id: terms 112 | attributes: 113 | label: Code of Conduct 114 | options: 115 | - label: I agree that I will not sell testnet funds for money in any way. 116 | required: true 117 | - label: I agree that I will not hoard testnet funds with no actual need. 118 | required: true 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Funding Vault 2 | 3 | Welcome to the repository for the Funding Vault contract, actively deployed on the [Holešovice](https://holesky.etherscan.io/address/0x610866c6089768da95524bcc4ce7db61eda3931c), [Sepolia](https://sepolia.etherscan.io/address/0x610866c6089768da95524bcc4ce7db61eda3931c) and [Hoodi](https://hoodi.etherscan.io/address/0x610866c6089768da95524bcc4ce7db61eda3931c) Testnets. This project serves as a reliable source of testnet funds for smaller faucets and projects that require a steady influx of funds. 4 | 5 | **Golden Rule**: Testnet funds may never be sold for profit or hoarded. They are public goods and should be utilized responsibly by entities with genuine needs. 6 | 7 | ## Usage 8 | 9 | Entities eligible for funding can claim funds regularly either through the [Web UI](https://fundingvault.ethpandaops.io/) or programmatically via direct calls to the vault contract. 10 | 11 | ### Applying for a Grant 12 | 13 | To ensure the integrity and purpose of the fund allocation, applicants must supply the following information for their application: 14 | 15 | - **Website**: Provide a link to a functioning website with comprehensive information about the project or company. 16 | - **Project Description**: Include a description of your project and a detailed explanation of how the funds will be used. 17 | - **Working Demo/Implementation**: Showcase a working demo or an implementation of the project part that requires ongoing funding to demonstrate its functionality and relevance. 18 | - **Protection Methods for Faucets**: If applying for a faucet, describe the methods employed to protect against abuse. Note that simple captcha protection is generally insufficient. 19 | 20 | If your project meets these criteria and needs ongoing testnet funds (for development teams or low-traffic faucets), please [open an issue](https://github.com/ethpandaops/fundingvault/issues/new?assignees=&labels=application&projects=&template=application.yaml&title=Funding+Request%3A+%3Ctitle%3E) in this repository with details about your requirements and the amount of ETH needed. 21 | 22 | Upon approval, you will receive an ERC721 token ("NFT") that grants access to the specified funds. It is your responsibility to secure this NFT, although you may transfer it as needed. 23 | 24 | **Ineligible Uses**: 25 | - **Token Liquidity**: Providing liquidity for tokens is not an appropriate use of these funds. 26 | - **Top-list Placement**: Funding intended to maintain a position on any kind of top-list is also considered inappropriate. 27 | 28 | Grants will continue as long as: 29 | - No rules are violated. 30 | - Funds are used appropriately. 31 | - Your project remains active. 32 | 33 | The grant is designed to last until the planned end of Sepolia in December 2026 and Holešovice in December 2028. 34 | 35 | ## Programmatic Claims 36 | 37 | Holders of the Grant NFT can claim funds within the granted limits by calling functions on the Funding Vault Contract. 38 | 39 | **Contract Address**: `0x610866c6089768dA95524bcc4cE7dB61eDa3931c` 40 | 41 | ### Available Claim Functions: 42 | 43 | - `claim(uint256 amount)`: Request and send the specified amount of funds (in wei) to the wallet initiating the call. 44 | - `claimTo(uint256 amount, address target)`: Request and send the specified amount of funds (in wei) to a target address. 45 | 46 | Specifying an amount of `0` will trigger a payout of all available funds. 47 | 48 | ### Timing of Claims 49 | 50 | The contract operates on a time-based system, allowing for both partial and full claims based on the accumulated available balance. 51 | 52 | - **Full Claims**: If you claim the full available amount, subsequent claims can be made within seconds, but only the funds that have accumulated since the last claim will be available. 53 | - **Partial Claims**: You can make a partial claim (e.g., 5k HolETH out of a 10k HolETH/month grant) if the available balance is sufficient. After a partial claim, the remaining balance continues to accumulate and can be claimed in subsequent calls. 54 | 55 | Grant holders should only claim the amounts of funds they actually need for immediate use and avoid hoarding funds for future use. Excessive accumulation without appropriate usage may prompt intervention to ensure fair resource distribution. 56 | 57 | ## Docs 58 | 59 | You can find a more detailed technical description of the FundingVault Contract here: [Technical Concept](https://github.com/ethpandaops/fundingvault/blob/master/fundingvault/docs/TechnicalConcept.md) 60 | 61 | ## Credits 62 | 63 | A big thanks to EF Testing for their testing efforts and to Nethermind Security for [auditing the smart contract](https://github.com/ethpandaops/fundingvault/blob/master/fundingvault/audit/NM-0234-Ethereum-Foundation-Final.pdf)! 64 | -------------------------------------------------------------------------------- /webui/src/components/grant_transfer/GrantTransfer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useWriteContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import { useState } from "react"; 9 | import { Modal } from 'react-bootstrap'; 10 | 11 | const GrantTransfer = (props: { grantId: number, name: string, owner: string, closeFn?: () => void }): React.ReactElement => { 12 | const { address, chain } = useAccount(); 13 | let chainConfig = ConfigForChainId(chain!.id)!; 14 | let [addressInput, setAddressInput] = useState(props.owner); 15 | 16 | const transferRequest = useWriteContract(); 17 | 18 | return ( 19 | <Modal show centered className="grant-manager-dialog transfer-dialog" size="lg" onHide={() => { 20 | if(props.closeFn) 21 | props.closeFn(); 22 | }}> 23 | <Modal.Header closeButton> 24 | <Modal.Title id="contained-modal-title-vcenter"> 25 | Transfer Grant 26 | </Modal.Title> 27 | </Modal.Header> 28 | <Modal.Body> 29 | <div className="container"> 30 | <div className="row my-2"> 31 | <div className="col-3"> 32 | Grant ID: 33 | </div> 34 | <div className="col-7"> 35 | {props.grantId} ({props.name}) 36 | </div> 37 | </div> 38 | <div className="row my-2"> 39 | <div className="col-3"> 40 | Current Owner: 41 | </div> 42 | <div className="col-7"> 43 | <a href={chainConfig.BlockExplorerUrl + "address/" + props.owner} target="_blank" rel="noreferrer">{props.owner}</a> 44 | </div> 45 | </div> 46 | <div className="row my-2"> 47 | <div className="col-3"> 48 | New Address: 49 | </div> 50 | <div className="col-7"> 51 | <input type="text" maxLength={42} className="form-control" onChange={(evt) => setAddressInput(evt.target.value)} value={addressInput} /> 52 | </div> 53 | </div> 54 | 55 | {transferRequest.isPending && transferRequest.data as any ? 56 | <div className="row mt-3"> 57 | <div className="col-12"> 58 | <div className="alert alert-info"> 59 | Transfer transaction pending... TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + transferRequest.data} target="_blank" rel="noreferrer">{transferRequest.data}</a> 60 | </div> 61 | </div> 62 | </div> 63 | : null} 64 | {transferRequest.isError ? 65 | <div className="row mt-3"> 66 | <div className="col-12"> 67 | <div className="alert alert-danger"> 68 | Transfer failed. {transferRequest.data as any ? <span>TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + transferRequest.data} target="_blank" rel="noreferrer">{transferRequest.data}</a></span> : null}<br /> 69 | {transferRequest.error.message} 70 | </div> 71 | </div> 72 | </div> 73 | : null} 74 | {transferRequest.isSuccess ? 75 | <div className="row mt-3"> 76 | <div className="col-12"> 77 | <div className="alert alert-success"> 78 | Transfer TX: <a className="txhash" href={chainConfig.BlockExplorerUrl + "tx/" + transferRequest.data} target="_blank" rel="noreferrer">{transferRequest.data}</a> 79 | </div> 80 | </div> 81 | </div> 82 | : null} 83 | </div> 84 | </Modal.Body> 85 | <Modal.Footer> 86 | <button onClick={(evt) => renameGrant(evt.target as HTMLButtonElement)} disabled={transferRequest.isPending} className="btn btn-primary"> 87 | Transfer Grant 88 | </button> 89 | <button onClick={() => { 90 | if(props.closeFn) 91 | props.closeFn(); 92 | }} className="btn btn-secondary"> 93 | Cancel 94 | </button> 95 | </Modal.Footer> 96 | </Modal> 97 | ); 98 | 99 | function renameGrant(button: HTMLButtonElement) { 100 | button.disabled = true; 101 | 102 | transferRequest.writeContract({ 103 | address: chainConfig.VaultContractAddr, 104 | account: address, 105 | abi: FundingVaultAbi, 106 | chainId: chainConfig.Chain.id, 107 | functionName: "transferGrant", 108 | args: [ props.grantId, addressInput ], 109 | onSuccess: () => { 110 | props.name = addressInput; 111 | }, 112 | }) 113 | } 114 | 115 | } 116 | 117 | export default GrantTransfer; -------------------------------------------------------------------------------- /webui/src/config.ts: -------------------------------------------------------------------------------- 1 | import { type Chain } from 'viem' 2 | import { holesky, sepolia } from "wagmi/chains"; 3 | import { defineChain } from 'viem'; 4 | 5 | export interface ChainConfig { 6 | VaultContractAddr: `0x${string}`; 7 | TokenContractAddr: `0x${string}`; 8 | TokenName: string; 9 | HumanNetworkName: string; 10 | Chain: Chain; 11 | BlockExplorerUrl: string; 12 | } 13 | 14 | export interface Config { 15 | AppVersion: string; 16 | AdminRole: string; 17 | ManagerRole: string; 18 | Chains: ChainConfig[]; 19 | } 20 | 21 | const sepoliaWithCustomRPC = Object.assign({}, sepolia, { 22 | rpcUrls: { 23 | default: { 24 | http: ['https://eth-sepolia.g.alchemy.com/v2/74gAuwdkOHanwiWJEl1sYb1rt-5XN3M0'], 25 | }, 26 | }, 27 | }); 28 | 29 | const holeskyWithCustomRPC = Object.assign({}, holesky, { 30 | rpcUrls: { 31 | default: { 32 | http: ['https://eth-holesky.g.alchemy.com/v2/74gAuwdkOHanwiWJEl1sYb1rt-5XN3M0'], 33 | }, 34 | }, 35 | }); 36 | 37 | /* 38 | const now = Math.floor((new Date()).getTime() / 1000); 39 | const iteration = Math.floor(((now - 1638471600) / 604800)); 40 | export const ephemery = defineChain({ 41 | id: 39438000 + iteration, 42 | name: 'Ephemery', 43 | nativeCurrency: { name: 'Ephemery Ether', symbol: 'Eph', decimals: 18 }, 44 | rpcUrls: { 45 | default: { 46 | http: ['https://rpc.bordel.wtf/test', 'https://otter.bordel.wtf/erigon'], 47 | }, 48 | }, 49 | blockExplorers: { 50 | default: { 51 | name: 'Etherscan', 52 | url: 'https://explorer.ephemery.dev/', 53 | apiUrl: 'https://explorer.ephemery.dev/api', 54 | }, 55 | }, 56 | contracts: { 57 | multicall3: { 58 | address: '0x1195eDfF07CC259DF22EF34Ee8FFa7d6C5C0A128', 59 | blockCreated: 1, 60 | }, 61 | ensRegistry: { address: '0x902740a7Bc8279b1A3beBDf91cf9A016235E8859' }, 62 | ensUniversalResolver: { 63 | address: '0xc8Af999e38273D658BE1b921b88A9Ddf005769cC', 64 | blockCreated: 1, 65 | }, 66 | }, 67 | testnet: true, 68 | }) 69 | */ 70 | 71 | export const hoodiWithCustomRPC = /*#__PURE__*/ defineChain({ 72 | id: 560048, 73 | name: 'Hoodi', 74 | nativeCurrency: { name: 'Hoodi Ether', symbol: 'ETH', decimals: 18 }, 75 | rpcUrls: { 76 | default: { 77 | http: [ 78 | 'https://rpc.hoodi.ethpandaops.io' 79 | ], 80 | }, 81 | }, 82 | blockExplorers: { 83 | default: { 84 | name: 'Etherscan', 85 | url: 'https://holesky.etherscan.io', 86 | }, 87 | }, 88 | 89 | contracts: { 90 | /* 91 | multicall3: { 92 | address: '0xca11bde05977b3631167028862be2a173976ca11', 93 | blockCreated: 77, 94 | }, 95 | ensRegistry: { 96 | address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', 97 | blockCreated: 801613, 98 | }, 99 | ensUniversalResolver: { 100 | address: '0xa6AC935D4971E3CD133b950aE053bECD16fE7f3b', 101 | blockCreated: 973484, 102 | }, 103 | */ 104 | }, 105 | 106 | testnet: true, 107 | }) 108 | 109 | const FundingVaultConfig: Config = { 110 | AppVersion: process.env.REACT_APP_GIT_VERSION as string, 111 | AdminRole: "0x0000000000000000000000000000000000000000000000000000000000000000", 112 | ManagerRole: "0xc7386e23c63a3088d7d0389761b7b890e58c103e1a12376eb26d3a4a04e2641b", 113 | Chains: [ 114 | { 115 | VaultContractAddr: "0x610866c6089768dA95524bcc4cE7dB61eDa3931c", 116 | TokenContractAddr: "0x97652A83CC29043fA9Be2781cc0038EBa70de911", 117 | TokenName: "HooETH", 118 | Chain: hoodiWithCustomRPC, 119 | HumanNetworkName: "Hoodi", 120 | BlockExplorerUrl: "https://hoodi.etherscan.io/", 121 | }, 122 | { 123 | VaultContractAddr: "0x610866c6089768dA95524bcc4cE7dB61eDa3931c", 124 | TokenContractAddr: "0x97652A83CC29043fA9Be2781cc0038EBa70de911", 125 | TokenName: "HolETH", 126 | Chain: holeskyWithCustomRPC, 127 | HumanNetworkName: "Holesky", 128 | BlockExplorerUrl: "https://holesky.etherscan.io/", 129 | }, 130 | { 131 | VaultContractAddr: "0x610866c6089768dA95524bcc4cE7dB61eDa3931c", 132 | TokenContractAddr: "0x97652A83CC29043fA9Be2781cc0038EBa70de911", 133 | TokenName: "SepETH", 134 | Chain: sepoliaWithCustomRPC, 135 | HumanNetworkName: "Sepolia", 136 | BlockExplorerUrl: "https://sepolia.etherscan.io/", 137 | }, 138 | /* 139 | { 140 | VaultContractAddr: "0x610866c6089768dA95524bcc4cE7dB61eDa3931c", 141 | TokenContractAddr: "0x97652A83CC29043fA9Be2781cc0038EBa70de911", 142 | TokenName: "EphETH", 143 | Chain: ephemery, 144 | HumanNetworkName: "Ephmery", 145 | BlockExplorerUrl: "https://explorer.ephemery.dev/", 146 | }, 147 | */ 148 | ], 149 | }; 150 | 151 | export var KnownChains = [ 152 | holeskyWithCustomRPC, 153 | hoodiWithCustomRPC, 154 | sepoliaWithCustomRPC, 155 | //ephemery, 156 | ]; 157 | 158 | let CurrentConfig = FundingVaultConfig; 159 | 160 | export default CurrentConfig; -------------------------------------------------------------------------------- /webui/src/components/grant_rename/GrantRename.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useWriteContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import { useState } from "react"; 9 | import { Modal } from 'react-bootstrap'; 10 | 11 | export interface IGrantDetails { 12 | claimInterval: bigint 13 | claimLimit: bigint 14 | claimTime: bigint 15 | dustBalance: bigint 16 | } 17 | 18 | function toHex(str) { 19 | var result = ''; 20 | var ccode; 21 | for (var i=0; i<str.length; i++) { 22 | ccode = str.charCodeAt(i); 23 | if(ccode) { 24 | result += ccode.toString(16); 25 | } 26 | } 27 | return result; 28 | } 29 | 30 | const GrantRename = (props: { grantId: number, name: string, closeFn?: () => void }): React.ReactElement => { 31 | const { address, chain } = useAccount(); 32 | let chainConfig = ConfigForChainId(chain!.id)!; 33 | let [nameInput, setNameInput] = useState(props.name); 34 | 35 | const renameRequest = useWriteContract(); 36 | 37 | return ( 38 | <Modal show centered className="grant-manager-dialog rename-dialog" size="lg" onHide={() => { 39 | if(props.closeFn) 40 | props.closeFn(); 41 | }}> 42 | <Modal.Header closeButton> 43 | <Modal.Title id="contained-modal-title-vcenter"> 44 | Rename Grant 45 | </Modal.Title> 46 | </Modal.Header> 47 | <Modal.Body> 48 | <div className="container"> 49 | <div className="row my-2"> 50 | <div className="col-2"> 51 | Grant ID: 52 | </div> 53 | <div className="col-8"> 54 | {props.grantId} ({props.name}) 55 | </div> 56 | </div> 57 | <div className="row my-2"> 58 | <div className="col-2"> 59 | New Name: 60 | </div> 61 | <div className="col-8"> 62 | <input type="text" maxLength={32} className="form-control" onChange={(evt) => setNameInput(evt.target.value)} value={nameInput} /> 63 | </div> 64 | </div> 65 | 66 | {renameRequest.isPending && renameRequest.data as any ? 67 | <div className="row mt-3"> 68 | <div className="col-12"> 69 | <div className="alert alert-info"> 70 | Rename transaction pending... TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + renameRequest.data} target="_blank" rel="noreferrer">{renameRequest.data}</a> 71 | </div> 72 | </div> 73 | </div> 74 | : null} 75 | {renameRequest.isError ? 76 | <div className="row mt-3"> 77 | <div className="col-12"> 78 | <div className="alert alert-danger"> 79 | Rename failed. {renameRequest.data as any ? <span>TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + renameRequest.data} target="_blank" rel="noreferrer">{renameRequest.data}</a></span> : null}<br /> 80 | {renameRequest.error.message} 81 | </div> 82 | </div> 83 | </div> 84 | : null} 85 | {renameRequest.isSuccess ? 86 | <div className="row mt-3"> 87 | <div className="col-12"> 88 | <div className="alert alert-success"> 89 | Rename TX: <a className="txhash" href={chainConfig.BlockExplorerUrl + "tx/" + renameRequest.data} target="_blank" rel="noreferrer">{renameRequest.data}</a> 90 | </div> 91 | </div> 92 | </div> 93 | : null} 94 | </div> 95 | </Modal.Body> 96 | <Modal.Footer> 97 | <button onClick={(evt) => renameGrant(evt.target as HTMLButtonElement)} disabled={renameRequest.isPending} className="btn btn-primary"> 98 | Rename Grant 99 | </button> 100 | <button onClick={() => { 101 | if(props.closeFn) 102 | props.closeFn(); 103 | }} className="btn btn-secondary"> 104 | Cancel 105 | </button> 106 | </Modal.Footer> 107 | </Modal> 108 | ); 109 | 110 | function renameGrant(button: HTMLButtonElement) { 111 | button.disabled = true; 112 | 113 | let hexName = toHex(nameInput); 114 | while(hexName.length < 64) { 115 | hexName += "0"; 116 | } 117 | hexName = "0x" + hexName; 118 | console.log(hexName); 119 | 120 | renameRequest.writeContract({ 121 | address: chainConfig.VaultContractAddr, 122 | account: address, 123 | abi: FundingVaultAbi, 124 | chainId: chainConfig.Chain.id, 125 | functionName: "renameGrant", 126 | args: [ props.grantId, hexName ], 127 | onSuccess: () => { 128 | props.name = nameInput; 129 | }, 130 | }) 131 | } 132 | 133 | } 134 | 135 | export default GrantRename; -------------------------------------------------------------------------------- /webui/src/components/background/Background.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { Suspense, useRef } from "react" 3 | import { Canvas, useFrame } from "@react-three/fiber" 4 | import { Physics, usePlane, useCompoundBody, useSphere } from "@react-three/cannon" 5 | import { Environment, Sky } from "@react-three/drei" 6 | import { EthLogoGeometry } from './EthLogoMesh'; 7 | import { DoubleSide, Vector3, MeshStandardMaterial, Matrix4 } from 'three'; 8 | 9 | let NUM = 40; 10 | let BOUNDING_SCALAR = 1; 11 | let SIZES = [0.8, 0.9, 1, 1.05, 1.2].map(size => size *= .6) 12 | let FORCE_MULTIPLIER = 10; 13 | let MOUSE_BALL_SIZE = 3; 14 | let SPAWN_SPACE = [20, 5, 0] 15 | let SPAWN_OFFSET = [8, 0, 0] 16 | const ethGeometry = EthLogoGeometry(BOUNDING_SCALAR); 17 | const diamondMaterial = new MeshStandardMaterial( 18 | { 19 | // color: "#8f99fb", 20 | color: "#e8cdfa", 21 | // color: "#f3e6fc", 22 | metalness: .5, 23 | roughness: .2, 24 | side: DoubleSide 25 | }) 26 | 27 | function InstancedClump({ mat = new Matrix4(), vec = new Vector3(), ...props }) { 28 | const [ref, api] = useCompoundBody(() => 29 | { 30 | let size = SIZES[Math.floor(Math.random() * SIZES.length)]; 31 | return { 32 | args: size, 33 | mass: size * 1.5, 34 | position: [ 35 | Math.random() * SPAWN_SPACE[0] - SPAWN_OFFSET[0], 36 | Math.random() * SPAWN_SPACE[1] - SPAWN_OFFSET[1], 37 | 0 38 | ], 39 | rotation: [ 40 | 2 * Math.PI * Math.random(), 41 | 2 * Math.PI * Math.random(), 42 | 2 * Math.PI * Math.random(), 43 | ], 44 | angularDamping: 0.15, 45 | linearDamping: 0.75 , 46 | shapes: [ 47 | { 48 | type: "Sphere", 49 | position: [0, 0, 0.025], // Cause some spin 50 | args: [size, size, size], 51 | } 52 | ], 53 | }}) 54 | 55 | useFrame(() => { 56 | for (let i = 0; i < NUM; i++) { 57 | ref.current.getMatrixAt(i, mat) 58 | api.at(i).applyForce(vec.setFromMatrixPosition(mat).normalize().multiplyScalar(-FORCE_MULTIPLIER).toArray(), [0, 0, 0]) 59 | } 60 | }) 61 | 62 | return ( 63 | <instancedMesh ref={ref} castShadow receiveShadow args={[null, null, NUM]} geometry={ethGeometry} material={diamondMaterial}></instancedMesh> 64 | ) 65 | } 66 | 67 | function Collisions(props: {mousePos?: any}) { 68 | usePlane(() => ({ position: [0, 0, 0], rotation: [0, 0, 0] })) 69 | usePlane(() => ({ position: [0, 0, 8], rotation: [0, -Math.PI, 0] })) 70 | usePlane(() => ({ position: [0, -6, 0], rotation: [-Math.PI / 2, 0, 0] })) 71 | usePlane(() => ({ position: [0, 6, 0], rotation: [Math.PI / 2, 0, 0] })) 72 | const [, api] = useSphere(() => ({ type: "Kinematic", args: [MOUSE_BALL_SIZE] })) 73 | 74 | return useFrame((_) => { 75 | api.position.set(props.mousePos.x, props.mousePos.y, 0) 76 | }) 77 | } 78 | 79 | const Background = (props: {children?: any}) => { 80 | let coords = useRef({x: 0, y: 0}) 81 | 82 | const handleMouseMove = (event) => { 83 | let x = event.clientX / window.innerWidth; 84 | let y = event.clientY / window.innerHeight; 85 | 86 | let x_adjusted = (x - 0.5) * 10; 87 | let y_adjusted = (y - 0.5) * -1 * 10; 88 | 89 | coords.x = x_adjusted; 90 | coords.y = y_adjusted; 91 | }; 92 | 93 | const handleTouchMove = (event) => { 94 | let x = event.changedTouches[0].clientX / window.innerWidth; 95 | let y = event.changedTouches[0].clientY / window.innerHeight; 96 | 97 | let x_adjusted = (x - 0.5) * 10; 98 | let y_adjusted = (y - 0.5) * -1 * 10; 99 | 100 | coords.x = x_adjusted; 101 | coords.y = y_adjusted; 102 | 103 | } 104 | 105 | return ( 106 | <div 107 | onMouseMove={handleMouseMove} 108 | onTouchMove={handleTouchMove} 109 | style={{margin: 0, padding: 0, height: "100vh", width: "100%"}}> 110 | 111 | {/* Main bkg component */} 112 | <div className="relative" style={{height: "100%", width: "100%"}}> 113 | <Canvas 114 | shadows 115 | dpr={1.5} 116 | camera={{ position: [0, 0, 20], fov: 35, near: 10, far: 40 }}> 117 | 118 | {/* Performance monitoring */} 119 | {/* <Perf position={'bottom-right'} /> */} 120 | 121 | <ambientLight intensity={0.5} /> 122 | <spotLight position={[20, 20, 25]} penumbra={1} angle={0.2} color="white" castShadow shadow-mapSize={[512, 512]} /> 123 | <directionalLight position={[0, 5, -4]} intensity={3} /> 124 | <directionalLight position={[0, -15, -0]} intensity={2} color="red" /> 125 | <directionalLight position={[-3, 10, 4]} intensity={2} color="orange"/> 126 | <directionalLight position={[4, 10, 4]} intensity={2} color="green"/> 127 | <directionalLight position={[-4, 10, 4]} intensity={2} color="blue"/> 128 | <Suspense fallback={null}> 129 | <Environment files="/adamsbridge.hdr" /> 130 | <Physics gravity={[0, 0, 0]}> 131 | <InstancedClump /> 132 | <Collisions mousePos={coords} /> 133 | </Physics> 134 | <Sky></Sky> 135 | </Suspense> 136 | </Canvas> 137 | </div> 138 | 139 | {/* The children */} 140 | {props.children} 141 | 142 | </div> 143 | ) 144 | } 145 | 146 | export default Background; -------------------------------------------------------------------------------- /webui/src/components/grantlist/GrantList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useReadContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import CurrentConfig from "../../config"; 9 | import { useEffect, useState } from "react"; 10 | import { toReadableDuration } from "../../utils/ConvertHelpers"; 11 | 12 | import "./GrantList.css" 13 | import GrantItem from "./GrantItem"; 14 | import GrantCreate from "../grant_create/GrantCreate"; 15 | 16 | 17 | const GrantList = (): React.ReactElement => { 18 | const { address, chain } = useAccount(); 19 | let chainConfig = ConfigForChainId(chain!.id)!; 20 | let [managerDialog, setManagerDialog] = useState<React.ReactElement>(null); 21 | 22 | const managerCooldownSettings = useReadContract({ 23 | address: chainConfig.VaultContractAddr, 24 | account: address, 25 | abi: FundingVaultAbi, 26 | chainId: chainConfig.Chain.id, 27 | functionName: "getManagerGrantLimits", 28 | args: [ ], 29 | }); 30 | const managerCooldown = useReadContract({ 31 | address: chainConfig.VaultContractAddr, 32 | account: address, 33 | abi: FundingVaultAbi, 34 | chainId: chainConfig.Chain.id, 35 | functionName: "getManagerCooldown", 36 | args: [ address ], 37 | }); 38 | const grantList = useReadContract({ 39 | address: chainConfig.VaultContractAddr, 40 | account: address, 41 | abi: FundingVaultAbi, 42 | chainId: chainConfig.Chain.id, 43 | functionName: "getGrants", 44 | args: [ ], 45 | }); 46 | const adminCheck = useReadContract(chain ? { 47 | address: chainConfig.VaultContractAddr, 48 | account: address, 49 | abi: FundingVaultAbi, 50 | chainId: chainConfig.Chain.id, 51 | functionName: "hasRole", 52 | args: [ CurrentConfig.AdminRole, address ], 53 | }: undefined); 54 | 55 | //console.log(grantDetails.data); 56 | useEffect(() => { 57 | const interval = setInterval(() => { 58 | console.log("refetch"); 59 | managerCooldown.refetch(); 60 | grantList.refetch(); 61 | }, 15000); 62 | return () => { 63 | clearInterval(interval); 64 | }; 65 | }, [managerCooldown, grantList]); 66 | 67 | //console.log(grantList.data) 68 | 69 | var grantListEls: React.ReactElement[] = []; 70 | if(Array.isArray(grantList.data)) { 71 | grantList.data.forEach((grant, index) => { 72 | grantListEls.push(<GrantItem tokenIdx={index} grant={grant} setDialog={setDialog} />) 73 | }); 74 | } 75 | 76 | var managerCooldownData = { 77 | locked: false, 78 | admin: false, 79 | cooldown: 0, 80 | threshold: 0, 81 | percent: 0, 82 | quotaAmount: 0, 83 | quotaTime: 0, 84 | lockQuota: 0, 85 | }; 86 | if(adminCheck.isFetched && managerCooldown.isFetched && managerCooldownSettings.isFetched) { 87 | let cooldownData = managerCooldown.data as bigint; 88 | let cooldownSettings = managerCooldownSettings.data as [bigint, bigint, number, number]; 89 | 90 | managerCooldownData.admin = adminCheck.data as boolean; 91 | managerCooldownData.cooldown = parseInt(cooldownData.toString()); 92 | managerCooldownData.quotaAmount = parseInt(cooldownSettings[0].toString()); 93 | managerCooldownData.quotaTime = parseInt(cooldownSettings[1].toString()); 94 | managerCooldownData.lockQuota = cooldownSettings[2]; 95 | managerCooldownData.threshold = cooldownSettings[3]; 96 | managerCooldownData.locked = managerCooldownData.cooldown > managerCooldownData.threshold; 97 | managerCooldownData.percent = managerCooldownData.locked ? 100 : Math.floor(100 / managerCooldownData.threshold * managerCooldownData.cooldown); 98 | console.log(managerCooldownData) 99 | } 100 | 101 | return ( 102 | <div className="page-block grants-page"> 103 | <h1>Grants List</h1> 104 | <div className="manager-limits"> 105 | Manager Cooldown: 106 | {managerCooldownData.admin ? <span className="mx-2 badge text-bg-success">Admin</span> : null} 107 | 108 | <div className="progress cooldown-bar"> 109 | <div 110 | className={["progress-bar", "cooldown-bar-value", managerCooldownData.locked ? "bg-warning" : ""].join(" ")} 111 | role="progressbar" style={{"width": managerCooldownData.percent + "%"}} 112 | aria-valuenow={managerCooldownData.percent} aria-valuemin={0} aria-valuemax={100} 113 | > 114 | {managerCooldownData.locked ? 115 | <div> 116 | locked for {toReadableDuration(managerCooldownData.cooldown - managerCooldownData.threshold)} 117 | </div> : 118 | <div> 119 | {managerCooldownData.percent} % ({toReadableDuration(managerCooldownData.cooldown)}) 120 | </div> } 121 | </div> 122 | </div> 123 | </div> 124 | 125 | <div className="grants-list mt-2"> 126 | <div className="create-btn"> 127 | <button className="btn btn-primary" onClick={() => { 128 | setDialog(( 129 | <GrantCreate closeFn={() => { setDialog(null); }} /> 130 | )); 131 | }}>New Grant</button> 132 | </div> 133 | <div className="pt-3">All existing grants:</div> 134 | <table className="table grants-table"> 135 | <thead> 136 | <tr> 137 | <th>ID</th> 138 | <th>Owner</th> 139 | <th>Name</th> 140 | <th>Granted Amount</th> 141 | <th>Claimable</th> 142 | <th>Total Claimed</th> 143 | <th>Actions</th> 144 | </tr> 145 | </thead> 146 | <tbody> 147 | {grantList.isFetched ? grantListEls : <tr><td colSpan={7}>Loading...</td></tr>} 148 | </tbody> 149 | </table> 150 | </div> 151 | 152 | {managerDialog} 153 | </div> 154 | ) 155 | 156 | function setDialog(element: React.ReactElement) { 157 | setManagerDialog(element); 158 | } 159 | 160 | } 161 | 162 | export default GrantList; -------------------------------------------------------------------------------- /webui/src/components/grant_update/GrantUpdate.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useWriteContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import { useState } from "react"; 9 | import { Modal } from 'react-bootstrap'; 10 | import { toReadableAmount, toReadableDuration } from "../../utils/ConvertHelpers"; 11 | 12 | const GrantRename = (props: { grantId: number, name: string, amount: number, interval: number, closeFn?: () => void }): React.ReactElement => { 13 | const { address, chain } = useAccount(); 14 | let chainConfig = ConfigForChainId(chain!.id)!; 15 | let [amountInput, setAmountInput] = useState(props.amount); 16 | let [intervalInput, setIntervalInput] = useState(props.interval); 17 | 18 | const updateRequest = useWriteContract(); 19 | 20 | let intervalOptions = [ 21 | { value: 86400, title: "1 day" }, 22 | { value: 604800, title: "1 week" }, 23 | { value: 1209600, title: "2 weeks" }, 24 | { value: 2592000, title: "1 month" }, 25 | ]; 26 | let intervalIsOption = false; 27 | intervalOptions.forEach((option) => { 28 | if(option.value == intervalInput) 29 | intervalIsOption = true; 30 | }); 31 | 32 | return ( 33 | <Modal show centered className="grant-manager-dialog update-dialog" size="lg" onHide={() => { 34 | if(props.closeFn) 35 | props.closeFn(); 36 | }}> 37 | <Modal.Header closeButton> 38 | <Modal.Title id="contained-modal-title-vcenter"> 39 | Update Grant 40 | </Modal.Title> 41 | </Modal.Header> 42 | <Modal.Body> 43 | <div className="container"> 44 | <div className="row my-2"> 45 | <div className="col-2"> 46 | Grant ID: 47 | </div> 48 | <div className="col-8"> 49 | {props.grantId} ({props.name}) 50 | </div> 51 | </div> 52 | <div className="row my-2"> 53 | <div className="col-2"> 54 | Allowance: 55 | </div> 56 | <div className="col-8"> 57 | {toReadableAmount(props.amount, 0, chainConfig.TokenName, 0)} / {toReadableDuration(props.interval)} 58 | </div> 59 | </div> 60 | <div className="row my-2"> 61 | <div className="col-2"> 62 | <span className="field-label">New Amount:</span> 63 | </div> 64 | <div className="col-4"> 65 | <input type="number" className="form-control" onChange={(evt) => setAmountInput(parseInt(evt.target.value))} value={amountInput.toString()} /> 66 | </div> 67 | <div className="col-2"> 68 | <span className="field-label">{chainConfig.TokenName}</span> 69 | </div> 70 | </div> 71 | <div className="row my-2"> 72 | <div className="col-2"> 73 | <span className="field-label">New Interval:</span> 74 | </div> 75 | <div className="col-5"> 76 | <select className="form-select" onChange={(evt) => { setIntervalInput(parseInt(evt.target.value)); }}> 77 | {intervalOptions.map((option) => { 78 | return ( 79 | <option key={option.value} value={option.value} selected={option.value == intervalInput}>{option.title}</option> 80 | ); 81 | })} 82 | <option value="0" selected={!intervalIsOption}>custom</option> 83 | </select> 84 | </div> 85 | </div> 86 | {intervalIsOption ? null : 87 | <div className="row"> 88 | <div className="col-2"> 89 | </div> 90 | <div className="col-4"> 91 | <input type="number" className="form-control" onChange={(evt) => setIntervalInput(parseInt(evt.target.value))} value={intervalInput.toString()} /> 92 | </div> 93 | <div className="col-1"> 94 | <span className="field-label">sec</span> 95 | </div> 96 | </div> 97 | } 98 | 99 | {updateRequest.isPending && updateRequest.data as any ? 100 | <div className="row mt-3"> 101 | <div className="col-12"> 102 | <div className="alert alert-info"> 103 | Update transaction pending... TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + updateRequest.data} target="_blank" rel="noreferrer">{updateRequest.data}</a> 104 | </div> 105 | </div> 106 | </div> 107 | : null} 108 | {updateRequest.isError ? 109 | <div className="row mt-3"> 110 | <div className="col-12"> 111 | <div className="alert alert-danger"> 112 | Update failed. {updateRequest.data as any ? <span>TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + updateRequest.data} target="_blank" rel="noreferrer">{updateRequest.data}</a></span> : null}<br /> 113 | {updateRequest.error.message} 114 | </div> 115 | </div> 116 | </div> 117 | : null} 118 | {updateRequest.isSuccess ? 119 | <div className="row mt-3"> 120 | <div className="col-12"> 121 | <div className="alert alert-success"> 122 | Rename TX: <a className="txhash" href={chainConfig.BlockExplorerUrl + "tx/" + updateRequest.data} target="_blank" rel="noreferrer">{updateRequest.data}</a> 123 | </div> 124 | </div> 125 | </div> 126 | : null} 127 | </div> 128 | </Modal.Body> 129 | <Modal.Footer> 130 | <button onClick={(evt) => updateGrant(evt.target as HTMLButtonElement)} disabled={updateRequest.isPending} className="btn btn-primary"> 131 | Update Grant 132 | </button> 133 | <button onClick={() => { 134 | if(props.closeFn) 135 | props.closeFn(); 136 | }} className="btn btn-secondary"> 137 | Cancel 138 | </button> 139 | </Modal.Footer> 140 | </Modal> 141 | ); 142 | 143 | function updateGrant(button: HTMLButtonElement) { 144 | button.disabled = true; 145 | 146 | updateRequest.writeContract({ 147 | address: chainConfig.VaultContractAddr, 148 | account: address, 149 | abi: FundingVaultAbi, 150 | chainId: chainConfig.Chain.id, 151 | functionName: "updateGrant", 152 | args: [ props.grantId, amountInput, intervalInput ], 153 | }) 154 | } 155 | 156 | } 157 | 158 | export default GrantRename; -------------------------------------------------------------------------------- /webui/src/components/grantlist/GrantItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useReadContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import VaultTokenAbi from "../../abi/VaultToken.json"; 9 | import { useEffect } from "react"; 10 | import { toReadableAmount, toReadableDuration } from "../../utils/ConvertHelpers"; 11 | import GrantRename from "../grant_rename/GrantRename"; 12 | import GrantUpdate from "../grant_update/GrantUpdate"; 13 | import GrantLock from "../grant_lock/GrantLock"; 14 | import GrantTransfer from "../grant_transfer/GrantTransfer"; 15 | import GrantDelete from "../grant_delete/GrantDelete"; 16 | 17 | export interface IGrantItemProps { 18 | tokenIdx: number 19 | grant: IGrantDetails 20 | setDialog: (element: React.ReactElement) => void; 21 | } 22 | 23 | export interface IGrantDetails { 24 | claimInterval: bigint 25 | claimLimit: bigint 26 | claimTime: bigint 27 | dustBalance: bigint 28 | } 29 | 30 | function hex2a(hexx: string): string { 31 | var hex = hexx.toString();//force conversion 32 | var str = ''; 33 | if(hex.length >= 2 && hex.substring(0, 2) == "0x") 34 | hex = hex.substring(2); 35 | var ccode; 36 | for (var i = 0; i < hex.length; i += 2) { 37 | ccode = parseInt(hex.substr(i, 2), 16); 38 | if(ccode) 39 | str += String.fromCharCode(ccode); 40 | } 41 | return str; 42 | } 43 | 44 | const GrantItem = (props: IGrantItemProps): React.ReactElement => { 45 | const { address, chain } = useAccount(); 46 | let chainConfig = ConfigForChainId(chain!.id)!; 47 | 48 | const tokenIdCall = useReadContract({ 49 | address: chainConfig.TokenContractAddr, 50 | account: address, 51 | abi: VaultTokenAbi, 52 | chainId: chainConfig.Chain.id, 53 | functionName: "tokenByIndex", 54 | args: [ props.tokenIdx ], 55 | }); 56 | const ownerOfCall = useReadContract(tokenIdCall.isFetched ? { 57 | address: chainConfig.TokenContractAddr, 58 | account: address, 59 | abi: VaultTokenAbi, 60 | chainId: chainConfig.Chain.id, 61 | functionName: "ownerOf", 62 | args: [ tokenIdCall.data ], 63 | } : undefined); 64 | const claimableBalanceCall = useReadContract(tokenIdCall.isFetched ? { 65 | address: chainConfig.VaultContractAddr, 66 | account: address, 67 | abi: FundingVaultAbi, 68 | chainId: chainConfig.Chain.id, 69 | functionName: "getClaimableBalance", 70 | args: [ tokenIdCall.data ], 71 | } : undefined); 72 | const totalClaimedCall = useReadContract(tokenIdCall.isFetched ? { 73 | address: chainConfig.VaultContractAddr, 74 | account: address, 75 | abi: FundingVaultAbi, 76 | chainId: chainConfig.Chain.id, 77 | functionName: "getGrantTotalClaimed", 78 | args: [ tokenIdCall.data ], 79 | } : undefined); 80 | const grantNameCall = useReadContract(tokenIdCall.isFetched ? { 81 | address: chainConfig.VaultContractAddr, 82 | account: address, 83 | abi: FundingVaultAbi, 84 | chainId: chainConfig.Chain.id, 85 | functionName: "getGrantName", 86 | args: [ tokenIdCall.data ], 87 | } : undefined); 88 | 89 | var grantName: string; 90 | if(grantNameCall.isFetched) { 91 | grantName = hex2a(grantNameCall.data as string); 92 | } 93 | 94 | useEffect(() => { 95 | const interval = setInterval(() => { 96 | console.log("refetch"); 97 | tokenIdCall.refetch(); 98 | ownerOfCall.refetch(); 99 | }, 15000); 100 | return () => { 101 | clearInterval(interval); 102 | }; 103 | }, [tokenIdCall, ownerOfCall]); 104 | 105 | return ( 106 | <tr> 107 | <td>{tokenIdCall.data?.toString() as string}</td> 108 | <td><a href={chainConfig.BlockExplorerUrl + "address/" + ownerOfCall.data?.toString()} target="_blank" rel="noreferrer">{ownerOfCall.data?.toString()}</a></td> 109 | <td> 110 | {grantName} 111 | <a href="#" className="grant-edit-btn" onClick={(evt) => { 112 | evt.preventDefault(); 113 | if(!tokenIdCall.isFetched) 114 | return; 115 | 116 | props.setDialog(( 117 | <GrantRename grantId={parseInt(tokenIdCall.data?.toString())} name={grantName} closeFn={() => { props.setDialog(null); }} /> 118 | )); 119 | }}> 120 | <i className="bi bi-pencil"></i> 121 | </a> 122 | </td> 123 | <td> 124 | {toReadableAmount(props.grant.claimLimit as bigint, 0, chainConfig.TokenName, 0)} / {toReadableDuration(props.grant.claimInterval)} 125 | <a href="#" className="grant-edit-btn" onClick={(evt) => { 126 | evt.preventDefault(); 127 | if(!tokenIdCall.isFetched) 128 | return; 129 | 130 | props.setDialog(( 131 | <GrantUpdate 132 | grantId={parseInt(tokenIdCall.data?.toString())} 133 | name={grantName} 134 | amount={parseInt(props.grant.claimLimit.toString())} 135 | interval={parseInt(props.grant.claimInterval.toString())} 136 | closeFn={() => { props.setDialog(null); }} 137 | /> 138 | )); 139 | }}> 140 | <i className="bi bi-pencil"></i> 141 | </a> 142 | </td> 143 | <td>{toReadableAmount(claimableBalanceCall.data as bigint, chain?.nativeCurrency.decimals, chainConfig.TokenName, 3)}</td> 144 | <td>{toReadableAmount(totalClaimedCall.data as bigint, chain?.nativeCurrency.decimals, chainConfig.TokenName, 3)}</td> 145 | <td> 146 | <a href="#" className="mx-1 grant-lock-btn" onClick={(evt) => { 147 | evt.preventDefault(); 148 | if(!tokenIdCall.isFetched) 149 | return; 150 | 151 | props.setDialog(( 152 | <GrantLock 153 | grantId={parseInt(tokenIdCall.data?.toString())} 154 | name={grantName} 155 | closeFn={() => { props.setDialog(null); }} 156 | /> 157 | )); 158 | }}> 159 | <i className="bi bi-lock-fill"></i> 160 | </a> 161 | <a href="#" className="mx-1 grant-transfer-btn" onClick={(evt) => { 162 | evt.preventDefault(); 163 | if(!tokenIdCall.isFetched) 164 | return; 165 | 166 | props.setDialog(( 167 | <GrantTransfer 168 | grantId={parseInt(tokenIdCall.data?.toString())} 169 | name={grantName} 170 | owner={ownerOfCall.data?.toString()} 171 | closeFn={() => { props.setDialog(null); }} 172 | /> 173 | )); 174 | }}> 175 | <i className="bi bi-arrow-up-right-circle-fill"></i> 176 | </a> 177 | <a href="#" className="mx-1 grant-delete-btn" onClick={(evt) => { 178 | evt.preventDefault(); 179 | if(!tokenIdCall.isFetched) 180 | return; 181 | 182 | props.setDialog(( 183 | <GrantDelete 184 | grantId={parseInt(tokenIdCall.data?.toString())} 185 | name={grantName} 186 | owner={ownerOfCall.data?.toString()} 187 | closeFn={() => { props.setDialog(null); }} 188 | /> 189 | )); 190 | }}> 191 | <i className="bi bi-trash3-fill"></i> 192 | </a> 193 | </td> 194 | </tr> 195 | ) 196 | 197 | 198 | } 199 | 200 | export default GrantItem; -------------------------------------------------------------------------------- /webui/src/components/grant_create/GrantCreate.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useWriteContract, 4 | } from "wagmi"; 5 | import { ConfigForChainId } from "../../utils/chaincfg"; 6 | 7 | import FundingVaultAbi from "../../abi/FundingVault.json"; 8 | import { useState } from "react"; 9 | import { Modal } from 'react-bootstrap'; 10 | import { isAddress } from "ethers"; 11 | 12 | function toHex(str) { 13 | var result = ''; 14 | var ccode; 15 | for (var i=0; i<str.length; i++) { 16 | ccode = str.charCodeAt(i); 17 | if(ccode) { 18 | result += ccode.toString(16); 19 | } 20 | } 21 | return result; 22 | } 23 | 24 | const GrantCreate = (props: { closeFn?: () => void }): React.ReactElement => { 25 | const { address, chain } = useAccount(); 26 | let chainConfig = ConfigForChainId(chain!.id)!; 27 | let [nameInput, setNameInput] = useState(""); 28 | let [addressInput, setAddressInput] = useState(""); 29 | let [amountInput, setAmountInput] = useState(10000); 30 | let [intervalInput, setIntervalInput] = useState(2592000); 31 | 32 | const createRequest = useWriteContract(); 33 | 34 | let intervalOptions = [ 35 | { value: 86400, title: "1 day" }, 36 | { value: 604800, title: "1 week" }, 37 | { value: 1209600, title: "2 weeks" }, 38 | { value: 2592000, title: "1 month" }, 39 | ]; 40 | let intervalIsOption = false; 41 | intervalOptions.forEach((option) => { 42 | if(option.value === intervalInput) 43 | intervalIsOption = true; 44 | }); 45 | 46 | return ( 47 | <Modal show centered className="grant-manager-dialog create-dialog" size="lg" onHide={() => { 48 | if(props.closeFn) 49 | props.closeFn(); 50 | }}> 51 | <Modal.Header closeButton> 52 | <Modal.Title id="contained-modal-title-vcenter"> 53 | Create Grant 54 | </Modal.Title> 55 | </Modal.Header> 56 | <Modal.Body> 57 | <div className="container"> 58 | <div className="row my-2"> 59 | <div className="col-2"> 60 | Name: 61 | </div> 62 | <div className="col-8"> 63 | <input type="text" maxLength={32} className="form-control" onChange={(evt) => setNameInput(evt.target.value)} value={nameInput} /> 64 | </div> 65 | </div> 66 | <div className="row my-2"> 67 | <div className="col-2"> 68 | Address: 69 | </div> 70 | <div className="col-8"> 71 | <input type="text" className="form-control" placeholder="0x..." onChange={(evt) => setAddressInput(evt.target.value)} value={addressInput} /> 72 | </div> 73 | </div> 74 | <div className="row my-2"> 75 | <div className="col-2"> 76 | <span className="field-label">Amount:</span> 77 | </div> 78 | <div className="col-4"> 79 | <input type="number" className="form-control" onChange={(evt) => setAmountInput(parseInt(evt.target.value))} value={amountInput.toString()} /> 80 | </div> 81 | <div className="col-2"> 82 | <span className="field-label">{chainConfig.TokenName}</span> 83 | </div> 84 | </div> 85 | <div className="row my-2"> 86 | <div className="col-2"> 87 | <span className="field-label">Interval:</span> 88 | </div> 89 | <div className="col-5"> 90 | <select className="form-select" onChange={(evt) => { setIntervalInput(parseInt(evt.target.value)); }}> 91 | {intervalOptions.map((option) => { 92 | return ( 93 | <option key={option.value} value={option.value} selected={option.value === intervalInput}>{option.title}</option> 94 | ); 95 | })} 96 | <option value="0" selected={!intervalIsOption}>custom</option> 97 | </select> 98 | </div> 99 | </div> 100 | {intervalIsOption ? null : 101 | <div className="row"> 102 | <div className="col-2"> 103 | </div> 104 | <div className="col-4"> 105 | <input type="number" className="form-control" onChange={(evt) => setIntervalInput(parseInt(evt.target.value))} value={intervalInput.toString()} /> 106 | </div> 107 | <div className="col-1"> 108 | <span className="field-label">sec</span> 109 | </div> 110 | </div> 111 | } 112 | 113 | {createRequest.isPending && createRequest.data as any ? 114 | <div className="row mt-3"> 115 | <div className="col-12"> 116 | <div className="alert alert-info"> 117 | Create transaction pending... TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + createRequest.data} target="_blank" rel="noreferrer">{createRequest.data}</a> 118 | </div> 119 | </div> 120 | </div> 121 | : null} 122 | {createRequest.isError ? 123 | <div className="row mt-3"> 124 | <div className="col-12"> 125 | <div className="alert alert-danger"> 126 | Create failed. {createRequest.data as any ? <span>TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + createRequest.data} target="_blank" rel="noreferrer">{createRequest.data}</a></span> : null}<br /> 127 | {createRequest.error.message} 128 | </div> 129 | </div> 130 | </div> 131 | : null} 132 | {createRequest.isSuccess ? 133 | <div className="row mt-3"> 134 | <div className="col-12"> 135 | <div className="alert alert-success"> 136 | Create TX: <a className="txhash" href={chainConfig.BlockExplorerUrl + "tx/" + createRequest.data} target="_blank" rel="noreferrer">{createRequest.data}</a> 137 | </div> 138 | </div> 139 | </div> 140 | : null} 141 | </div> 142 | </Modal.Body> 143 | <Modal.Footer> 144 | <button onClick={(evt) => createGrant(evt.target as HTMLButtonElement)} disabled={createRequest.isPending} className="btn btn-primary"> 145 | Create Grant 146 | </button> 147 | <button onClick={() => { 148 | if(props.closeFn) 149 | props.closeFn(); 150 | }} className="btn btn-secondary"> 151 | Cancel 152 | </button> 153 | </Modal.Footer> 154 | </Modal> 155 | ); 156 | 157 | function createGrant(button: HTMLButtonElement) { 158 | button.disabled = true; 159 | 160 | if(!isAddress(addressInput)) { 161 | alert("Provided target address '" + addressInput + "' is invalid."); 162 | button.disabled = false; 163 | return; 164 | } 165 | 166 | let hexName = toHex(nameInput); 167 | while(hexName.length < 64) { 168 | hexName += "0"; 169 | } 170 | hexName = "0x" + hexName; 171 | 172 | createRequest.writeContract({ 173 | address: chainConfig.VaultContractAddr, 174 | account: address, 175 | abi: FundingVaultAbi, 176 | chainId: chainConfig.Chain.id, 177 | functionName: "createGrant", 178 | args: [ addressInput, amountInput, intervalInput, hexName ], 179 | }) 180 | } 181 | 182 | } 183 | 184 | export default GrantCreate; -------------------------------------------------------------------------------- /webui/src/components/vaultinfo/ClaimForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useAccount, 3 | useReadContract, 4 | useWriteContract, 5 | } from "wagmi"; 6 | import { ConfigForChainId } from "../../utils/chaincfg"; 7 | 8 | import FundingVaultAbi from "../../abi/FundingVault.json"; 9 | import { useEffect, useState } from "react"; 10 | import { toBigintUnit, toDecimalUnit, toReadableAmount, toReadableDuration } from "../../utils/ConvertHelpers"; 11 | import { isAddress } from "ethers"; 12 | 13 | import "./ClaimForm.css" 14 | 15 | interface IGrantDetails { 16 | claimInterval: bigint 17 | claimLimit: bigint 18 | claimTime: bigint 19 | dustBalance: bigint 20 | } 21 | 22 | const ClaimForm = (props: { grantId: number }): React.ReactElement => { 23 | const { address, chain } = useAccount(); 24 | let chainConfig = ConfigForChainId(chain!.id)!; 25 | let [claimAmount, setClaimAmount] = useState("10"); 26 | let [claimTarget, setClaimTarget] = useState(""); 27 | let [claimAll, setClaimAll] = useState<boolean>(false); 28 | let [claimTargetCustom, setClaimTargetCustom] = useState<boolean>(false); 29 | 30 | const grantDetails = useReadContract({ 31 | address: chainConfig.VaultContractAddr, 32 | account: address, 33 | abi: FundingVaultAbi, 34 | chainId: chainConfig.Chain.id, 35 | functionName: "getGrant", 36 | args: [ props.grantId ], 37 | }); 38 | const claimableBalance = useReadContract({ 39 | address: chainConfig.VaultContractAddr, 40 | account: address, 41 | abi: FundingVaultAbi, 42 | chainId: chainConfig.Chain.id, 43 | functionName: "getClaimableBalance", 44 | args: [ props.grantId ], 45 | }); 46 | const claimRequest = useWriteContract(); 47 | 48 | //console.log(grantDetails.data); 49 | useEffect(() => { 50 | const interval = setInterval(() => { 51 | console.log("refetch"); 52 | claimableBalance.refetch(); 53 | }, 15000); 54 | return () => { 55 | clearInterval(interval); 56 | }; 57 | }, [claimableBalance]); 58 | 59 | let maxAmount = toDecimalUnit(claimableBalance.data as bigint, chain?.nativeCurrency.decimals); 60 | if(isNaN(maxAmount)) { 61 | maxAmount = 0; 62 | } 63 | maxAmount = Math.round(maxAmount * 1000) / 1000; 64 | 65 | if(parseInt(claimAmount) > maxAmount) { 66 | setClaimAmount(maxAmount.toString()); 67 | } else if(parseInt(claimAmount) < 0) { 68 | setClaimAmount("0"); 69 | } 70 | 71 | return ( 72 | <div> 73 | <table className="details-table"> 74 | <tbody> 75 | <tr> 76 | <td className="prop">Your claimable balance:</td> 77 | <td className="value">{toReadableAmount(claimableBalance.data as bigint, chain?.nativeCurrency.decimals, chainConfig.TokenName, 3)}</td> 78 | </tr> 79 | {grantDetails.data ? 80 | <tr> 81 | <td className="prop">Your allowance:</td> 82 | <td className="value">{toReadableAmount((grantDetails.data as IGrantDetails)?.claimLimit, 0, chainConfig.TokenName, 0)} per {toReadableDuration((grantDetails.data as IGrantDetails)?.claimInterval)}</td> 83 | </tr> 84 | : null} 85 | </tbody> 86 | </table> 87 | <div className="claim-form container"> 88 | <b>Claim Funds</b> 89 | 90 | <div className="row mt-2"> 91 | <div className="col-6"> 92 | Amount ({chainConfig.TokenName}) 93 | </div> 94 | <div className="col-6"> 95 | <div className="form-check"> 96 | <input className="form-check-input" type="checkbox" value="" id="claimAll" onChange={(evt) => setClaimAll(evt.target.checked)} checked={claimAll} /> 97 | <label className="form-check-label" htmlFor="claimAll"> 98 | Claim all claimable balance 99 | </label> 100 | </div> 101 | </div> 102 | </div> 103 | <div className="row"> 104 | <div className="col-5"> 105 | <input type="number" className="form-control" placeholder={claimAll ? maxAmount.toString() : "0"} onChange={(evt) => setClaimAmount(evt.target.value)} value={claimAll ? maxAmount.toString() : claimAmount} disabled={claimAll} /> 106 | </div> 107 | <div className="col-1"></div> 108 | <div className="col-6"> 109 | <input type="range" className="form-range" max={maxAmount} onChange={(evt) => setClaimAmount(evt.target.value)} value={claimAll ? maxAmount.toString() : claimAmount} disabled={claimAll} /> 110 | </div> 111 | </div> 112 | 113 | <div className="row mt-2"> 114 | <div className="col-6"> 115 | Target Wallet 116 | </div> 117 | <div className="col-6"> 118 | <div className="form-check"> 119 | <input className="form-check-input" type="checkbox" value="" id="claimTargetCustom" onChange={(evt) => setClaimTargetCustom(evt.target.checked)} checked={claimTargetCustom} /> 120 | <label className="form-check-label" htmlFor="claimTargetCustom"> 121 | Send to another wallet 122 | </label> 123 | </div> 124 | </div> 125 | </div> 126 | <div className="row"> 127 | <div className="col-12"> 128 | <input type="text" className="form-control" placeholder={claimTargetCustom ? "0x..." : address} onChange={(evt) => setClaimTarget(evt.target.value)} value={claimTarget} disabled={!claimTargetCustom} /> 129 | </div> 130 | </div> 131 | 132 | <div className="row mt-3"> 133 | <div className="col-12"> 134 | <button className="btn btn-primary claim-button" onClick={(evt) => requestFunds(evt.target as HTMLButtonElement)} disabled={claimRequest.isPending}>Request Funds</button> 135 | </div> 136 | </div> 137 | 138 | {claimRequest.isPending && claimRequest.data as any ? 139 | <div className="row mt-3"> 140 | <div className="col-12"> 141 | <div className="alert alert-info"> 142 | Claim transaction pending... TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + claimRequest.data} target="_blank" rel="noreferrer">{claimRequest.data}</a> 143 | </div> 144 | </div> 145 | </div> 146 | : null} 147 | {claimRequest.isError ? 148 | <div className="row mt-3"> 149 | <div className="col-12"> 150 | <div className="alert alert-danger"> 151 | Claim failed. {claimRequest.data as any ? <span>TX: <a href={chainConfig.BlockExplorerUrl + "tx/" + claimRequest.data} target="_blank" rel="noreferrer">{claimRequest.data}</a></span> : null}<br /> 152 | {claimRequest.error.message} 153 | </div> 154 | </div> 155 | </div> 156 | : null} 157 | {claimRequest.isSuccess ? 158 | <div className="row mt-3"> 159 | <div className="col-12"> 160 | <div className="alert alert-success"> 161 | Claim TX: <a className="txhash" href={chainConfig.BlockExplorerUrl + "tx/" + claimRequest.data} target="_blank" rel="noreferrer">{claimRequest.data}</a> 162 | </div> 163 | </div> 164 | </div> 165 | : null} 166 | </div> 167 | 168 | </div> 169 | ) 170 | 171 | function requestFunds(button: HTMLButtonElement) { 172 | button.disabled = true; 173 | 174 | let targetAddress = claimTarget; 175 | if(claimTargetCustom && !isAddress(targetAddress)) { 176 | alert("Provided target address '" + targetAddress + "' is invalid."); 177 | button.disabled = false; 178 | return; 179 | } 180 | 181 | let amount = parseInt(claimAmount); 182 | if(claimAll) { 183 | amount = 0; 184 | } else if(amount == 0 || amount > maxAmount) { 185 | alert("Desired amount '" + claimAmount + "' is invalid."); 186 | button.disabled = false; 187 | return; 188 | } 189 | let amountWei = toBigintUnit(amount, chain?.nativeCurrency.decimals); 190 | 191 | let callfn = "claim" 192 | let callArgs: any[] = [ props.grantId, amountWei ]; 193 | if (claimTargetCustom && targetAddress.toLowerCase() != address?.toLowerCase()) { 194 | callfn = "claimTo"; 195 | callArgs.push(targetAddress); 196 | } 197 | 198 | claimRequest.writeContract({ 199 | address: chainConfig.VaultContractAddr, 200 | account: address, 201 | abi: FundingVaultAbi, 202 | chainId: chainConfig.Chain.id, 203 | functionName: callfn, 204 | args: callArgs, 205 | }) 206 | } 207 | } 208 | 209 | export default ClaimForm; -------------------------------------------------------------------------------- /webui/src/abi/VaultToken.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [ 4 | { 5 | "internalType": "address", 6 | "name": "fundingVault", 7 | "type": "address" 8 | } 9 | ], 10 | "stateMutability": "nonpayable", 11 | "type": "constructor" 12 | }, 13 | { 14 | "anonymous": false, 15 | "inputs": [ 16 | { 17 | "indexed": true, 18 | "internalType": "address", 19 | "name": "owner", 20 | "type": "address" 21 | }, 22 | { 23 | "indexed": true, 24 | "internalType": "address", 25 | "name": "approved", 26 | "type": "address" 27 | }, 28 | { 29 | "indexed": true, 30 | "internalType": "uint256", 31 | "name": "tokenId", 32 | "type": "uint256" 33 | } 34 | ], 35 | "name": "Approval", 36 | "type": "event" 37 | }, 38 | { 39 | "anonymous": false, 40 | "inputs": [ 41 | { 42 | "indexed": true, 43 | "internalType": "address", 44 | "name": "owner", 45 | "type": "address" 46 | }, 47 | { 48 | "indexed": true, 49 | "internalType": "address", 50 | "name": "operator", 51 | "type": "address" 52 | }, 53 | { 54 | "indexed": false, 55 | "internalType": "bool", 56 | "name": "approved", 57 | "type": "bool" 58 | } 59 | ], 60 | "name": "ApprovalForAll", 61 | "type": "event" 62 | }, 63 | { 64 | "anonymous": false, 65 | "inputs": [ 66 | { 67 | "indexed": true, 68 | "internalType": "address", 69 | "name": "from", 70 | "type": "address" 71 | }, 72 | { 73 | "indexed": true, 74 | "internalType": "address", 75 | "name": "to", 76 | "type": "address" 77 | }, 78 | { 79 | "indexed": true, 80 | "internalType": "uint256", 81 | "name": "tokenId", 82 | "type": "uint256" 83 | } 84 | ], 85 | "name": "Transfer", 86 | "type": "event" 87 | }, 88 | { 89 | "inputs": [ 90 | { 91 | "internalType": "address", 92 | "name": "to", 93 | "type": "address" 94 | }, 95 | { 96 | "internalType": "uint256", 97 | "name": "tokenId", 98 | "type": "uint256" 99 | } 100 | ], 101 | "name": "approve", 102 | "outputs": [], 103 | "stateMutability": "nonpayable", 104 | "type": "function" 105 | }, 106 | { 107 | "inputs": [ 108 | { 109 | "internalType": "address", 110 | "name": "owner", 111 | "type": "address" 112 | } 113 | ], 114 | "name": "balanceOf", 115 | "outputs": [ 116 | { 117 | "internalType": "uint256", 118 | "name": "", 119 | "type": "uint256" 120 | } 121 | ], 122 | "stateMutability": "view", 123 | "type": "function" 124 | }, 125 | { 126 | "inputs": [ 127 | { 128 | "internalType": "uint256", 129 | "name": "tokenId", 130 | "type": "uint256" 131 | } 132 | ], 133 | "name": "getApproved", 134 | "outputs": [ 135 | { 136 | "internalType": "address", 137 | "name": "", 138 | "type": "address" 139 | } 140 | ], 141 | "stateMutability": "view", 142 | "type": "function" 143 | }, 144 | { 145 | "inputs": [], 146 | "name": "getVault", 147 | "outputs": [ 148 | { 149 | "internalType": "address", 150 | "name": "", 151 | "type": "address" 152 | } 153 | ], 154 | "stateMutability": "view", 155 | "type": "function" 156 | }, 157 | { 158 | "inputs": [ 159 | { 160 | "internalType": "address", 161 | "name": "owner", 162 | "type": "address" 163 | }, 164 | { 165 | "internalType": "address", 166 | "name": "operator", 167 | "type": "address" 168 | } 169 | ], 170 | "name": "isApprovedForAll", 171 | "outputs": [ 172 | { 173 | "internalType": "bool", 174 | "name": "", 175 | "type": "bool" 176 | } 177 | ], 178 | "stateMutability": "view", 179 | "type": "function" 180 | }, 181 | { 182 | "inputs": [], 183 | "name": "name", 184 | "outputs": [ 185 | { 186 | "internalType": "string", 187 | "name": "", 188 | "type": "string" 189 | } 190 | ], 191 | "stateMutability": "view", 192 | "type": "function" 193 | }, 194 | { 195 | "inputs": [ 196 | { 197 | "internalType": "uint256", 198 | "name": "tokenId", 199 | "type": "uint256" 200 | } 201 | ], 202 | "name": "ownerOf", 203 | "outputs": [ 204 | { 205 | "internalType": "address", 206 | "name": "", 207 | "type": "address" 208 | } 209 | ], 210 | "stateMutability": "view", 211 | "type": "function" 212 | }, 213 | { 214 | "inputs": [ 215 | { 216 | "internalType": "address", 217 | "name": "from", 218 | "type": "address" 219 | }, 220 | { 221 | "internalType": "address", 222 | "name": "to", 223 | "type": "address" 224 | }, 225 | { 226 | "internalType": "uint256", 227 | "name": "tokenId", 228 | "type": "uint256" 229 | } 230 | ], 231 | "name": "safeTransferFrom", 232 | "outputs": [], 233 | "stateMutability": "nonpayable", 234 | "type": "function" 235 | }, 236 | { 237 | "inputs": [ 238 | { 239 | "internalType": "address", 240 | "name": "from", 241 | "type": "address" 242 | }, 243 | { 244 | "internalType": "address", 245 | "name": "to", 246 | "type": "address" 247 | }, 248 | { 249 | "internalType": "uint256", 250 | "name": "tokenId", 251 | "type": "uint256" 252 | }, 253 | { 254 | "internalType": "bytes", 255 | "name": "data", 256 | "type": "bytes" 257 | } 258 | ], 259 | "name": "safeTransferFrom", 260 | "outputs": [], 261 | "stateMutability": "nonpayable", 262 | "type": "function" 263 | }, 264 | { 265 | "inputs": [ 266 | { 267 | "internalType": "address", 268 | "name": "operator", 269 | "type": "address" 270 | }, 271 | { 272 | "internalType": "bool", 273 | "name": "approved", 274 | "type": "bool" 275 | } 276 | ], 277 | "name": "setApprovalForAll", 278 | "outputs": [], 279 | "stateMutability": "nonpayable", 280 | "type": "function" 281 | }, 282 | { 283 | "inputs": [ 284 | { 285 | "internalType": "bytes4", 286 | "name": "interfaceId", 287 | "type": "bytes4" 288 | } 289 | ], 290 | "name": "supportsInterface", 291 | "outputs": [ 292 | { 293 | "internalType": "bool", 294 | "name": "", 295 | "type": "bool" 296 | } 297 | ], 298 | "stateMutability": "view", 299 | "type": "function" 300 | }, 301 | { 302 | "inputs": [], 303 | "name": "symbol", 304 | "outputs": [ 305 | { 306 | "internalType": "string", 307 | "name": "", 308 | "type": "string" 309 | } 310 | ], 311 | "stateMutability": "view", 312 | "type": "function" 313 | }, 314 | { 315 | "inputs": [ 316 | { 317 | "internalType": "uint256", 318 | "name": "index", 319 | "type": "uint256" 320 | } 321 | ], 322 | "name": "tokenByIndex", 323 | "outputs": [ 324 | { 325 | "internalType": "uint256", 326 | "name": "", 327 | "type": "uint256" 328 | } 329 | ], 330 | "stateMutability": "view", 331 | "type": "function" 332 | }, 333 | { 334 | "inputs": [ 335 | { 336 | "internalType": "address", 337 | "name": "owner", 338 | "type": "address" 339 | }, 340 | { 341 | "internalType": "uint256", 342 | "name": "index", 343 | "type": "uint256" 344 | } 345 | ], 346 | "name": "tokenOfOwnerByIndex", 347 | "outputs": [ 348 | { 349 | "internalType": "uint256", 350 | "name": "", 351 | "type": "uint256" 352 | } 353 | ], 354 | "stateMutability": "view", 355 | "type": "function" 356 | }, 357 | { 358 | "inputs": [ 359 | { 360 | "internalType": "uint256", 361 | "name": "tokenId", 362 | "type": "uint256" 363 | } 364 | ], 365 | "name": "tokenURI", 366 | "outputs": [ 367 | { 368 | "internalType": "string", 369 | "name": "", 370 | "type": "string" 371 | } 372 | ], 373 | "stateMutability": "view", 374 | "type": "function" 375 | }, 376 | { 377 | "inputs": [ 378 | { 379 | "internalType": "uint64", 380 | "name": "tokenId", 381 | "type": "uint64" 382 | }, 383 | { 384 | "internalType": "address", 385 | "name": "targetAddr", 386 | "type": "address" 387 | } 388 | ], 389 | "name": "tokenUpdate", 390 | "outputs": [], 391 | "stateMutability": "nonpayable", 392 | "type": "function" 393 | }, 394 | { 395 | "inputs": [], 396 | "name": "totalSupply", 397 | "outputs": [ 398 | { 399 | "internalType": "uint256", 400 | "name": "", 401 | "type": "uint256" 402 | } 403 | ], 404 | "stateMutability": "view", 405 | "type": "function" 406 | }, 407 | { 408 | "inputs": [ 409 | { 410 | "internalType": "address", 411 | "name": "from", 412 | "type": "address" 413 | }, 414 | { 415 | "internalType": "address", 416 | "name": "to", 417 | "type": "address" 418 | }, 419 | { 420 | "internalType": "uint256", 421 | "name": "tokenId", 422 | "type": "uint256" 423 | } 424 | ], 425 | "name": "transferFrom", 426 | "outputs": [], 427 | "stateMutability": "nonpayable", 428 | "type": "function" 429 | }, 430 | { 431 | "stateMutability": "payable", 432 | "type": "receive" 433 | } 434 | ] -------------------------------------------------------------------------------- /fundingvault/contract-json/FundingVaultProxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "Solidity", 3 | "sources": { 4 | "contracts/FundingVaultProxy.sol": { 5 | "content": "// SPDX-License-Identifier: MIT\r\npragma solidity ^0.8.21;\r\n\r\n/*\r\n##################################################################\r\n# Holešovice Funding Vault #\r\n# #\r\n# This contract is used to distribute fund reserves to faucets #\r\n# or other projects that have a ongoing need for testnet funds. #\r\n# #\r\n# see https://dev.pk910.de/ethvault by pk910.eth #\r\n##################################################################\r\n*/\r\n\r\nimport \"@openzeppelin/contracts/utils/Address.sol\";\r\nimport \"./FundingVaultProxyStorage.sol\";\r\n\r\n\r\ncontract FundingVaultProxy is FundingVaultProxyStorage {\r\n\r\n constructor() {\r\n _manager = msg.sender;\r\n }\r\n\r\n modifier ifManager() {\r\n if (msg.sender == _manager) {\r\n _;\r\n } else {\r\n _fallback();\r\n }\r\n }\r\n\r\n function _fallback() internal {\r\n require(_implementation != address(0), \"no implementation\");\r\n _delegate(_implementation);\r\n }\r\n\r\n function _delegate(address impl) internal virtual {\r\n assembly {\r\n calldatacopy(0, 0, calldatasize())\r\n let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)\r\n returndatacopy(0, 0, returndatasize())\r\n switch result\r\n case 0 {\r\n revert(0, returndatasize())\r\n }\r\n default {\r\n return(0, returndatasize())\r\n }\r\n }\r\n }\r\n\r\n fallback() external payable {\r\n _fallback();\r\n }\r\n\r\n receive() external payable {\r\n _fallback();\r\n }\r\n\r\n function implementation() public view returns (address) {\r\n return _implementation;\r\n }\r\n\r\n function upgradeTo(address addr) external ifManager {\r\n _implementation = addr;\r\n }\r\n\r\n function upgradeToAndCall(address addr, bytes calldata data) external ifManager {\r\n _implementation = addr;\r\n Address.functionDelegateCall(addr, data);\r\n }\r\n\r\n}\r\n" 6 | }, 7 | "contracts/FundingVaultProxyStorage.sol": { 8 | "content": "// SPDX-License-Identifier: MIT\r\npragma solidity ^0.8.21;\r\n\r\ncontract FundingVaultProxyStorage {\r\n // slot 0x00 - manager address (admin)\r\n address internal _manager;\r\n uint96 internal __unused0;\r\n // slot 0x01 - implementation address\r\n address internal _implementation;\r\n uint96 internal __unused1;\r\n}\r\n" 9 | }, 10 | "@openzeppelin/contracts/utils/Address.sol": { 11 | "content": "// SPDX-License-Identifier: MIT\n// OpenZeppelin Contracts (last updated v4.9.0) (utils/Address.sol)\n\npragma solidity ^0.8.1;\n\n/**\n * @dev Collection of functions related to the address type\n */\nlibrary Address {\n /**\n * @dev Returns true if `account` is a contract.\n *\n * [IMPORTANT]\n * ====\n * It is unsafe to assume that an address for which this function returns\n * false is an externally-owned account (EOA) and not a contract.\n *\n * Among others, `isContract` will return false for the following\n * types of addresses:\n *\n * - an externally-owned account\n * - a contract in construction\n * - an address where a contract will be created\n * - an address where a contract lived, but was destroyed\n *\n * Furthermore, `isContract` will also return true if the target contract within\n * the same transaction is already scheduled for destruction by `SELFDESTRUCT`,\n * which only has an effect at the end of a transaction.\n * ====\n *\n * [IMPORTANT]\n * ====\n * You shouldn't rely on `isContract` to protect against flash loan attacks!\n *\n * Preventing calls from contracts is highly discouraged. It breaks composability, breaks support for smart wallets\n * like Gnosis Safe, and does not provide security since it can be circumvented by calling from a contract\n * constructor.\n * ====\n */\n function isContract(address account) internal view returns (bool) {\n // This method relies on extcodesize/address.code.length, which returns 0\n // for contracts in construction, since the code is only stored at the end\n // of the constructor execution.\n\n return account.code.length > 0;\n }\n\n /**\n * @dev Replacement for Solidity's `transfer`: sends `amount` wei to\n * `recipient`, forwarding all available gas and reverting on errors.\n *\n * https://eips.ethereum.org/EIPS/eip-1884[EIP1884] increases the gas cost\n * of certain opcodes, possibly making contracts go over the 2300 gas limit\n * imposed by `transfer`, making them unable to receive funds via\n * `transfer`. {sendValue} removes this limitation.\n *\n * https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/[Learn more].\n *\n * IMPORTANT: because control is transferred to `recipient`, care must be\n * taken to not create reentrancy vulnerabilities. Consider using\n * {ReentrancyGuard} or the\n * https://solidity.readthedocs.io/en/v0.8.0/security-considerations.html#use-the-checks-effects-interactions-pattern[checks-effects-interactions pattern].\n */\n function sendValue(address payable recipient, uint256 amount) internal {\n require(address(this).balance >= amount, \"Address: insufficient balance\");\n\n (bool success, ) = recipient.call{value: amount}(\"\");\n require(success, \"Address: unable to send value, recipient may have reverted\");\n }\n\n /**\n * @dev Performs a Solidity function call using a low level `call`. A\n * plain `call` is an unsafe replacement for a function call: use this\n * function instead.\n *\n * If `target` reverts with a revert reason, it is bubbled up by this\n * function (like regular Solidity function calls).\n *\n * Returns the raw returned data. To convert to the expected return value,\n * use https://solidity.readthedocs.io/en/latest/units-and-global-variables.html?highlight=abi.decode#abi-encoding-and-decoding-functions[`abi.decode`].\n *\n * Requirements:\n *\n * - `target` must be a contract.\n * - calling `target` with `data` must not revert.\n *\n * _Available since v3.1._\n */\n function functionCall(address target, bytes memory data) internal returns (bytes memory) {\n return functionCallWithValue(target, data, 0, \"Address: low-level call failed\");\n }\n\n /**\n * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`], but with\n * `errorMessage` as a fallback revert reason when `target` reverts.\n *\n * _Available since v3.1._\n */\n function functionCall(\n address target,\n bytes memory data,\n string memory errorMessage\n ) internal returns (bytes memory) {\n return functionCallWithValue(target, data, 0, errorMessage);\n }\n\n /**\n * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],\n * but also transferring `value` wei to `target`.\n *\n * Requirements:\n *\n * - the calling contract must have an ETH balance of at least `value`.\n * - the called Solidity function must be `payable`.\n *\n * _Available since v3.1._\n */\n function functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) {\n return functionCallWithValue(target, data, value, \"Address: low-level call with value failed\");\n }\n\n /**\n * @dev Same as {xref-Address-functionCallWithValue-address-bytes-uint256-}[`functionCallWithValue`], but\n * with `errorMessage` as a fallback revert reason when `target` reverts.\n *\n * _Available since v3.1._\n */\n function functionCallWithValue(\n address target,\n bytes memory data,\n uint256 value,\n string memory errorMessage\n ) internal returns (bytes memory) {\n require(address(this).balance >= value, \"Address: insufficient balance for call\");\n (bool success, bytes memory returndata) = target.call{value: value}(data);\n return verifyCallResultFromTarget(target, success, returndata, errorMessage);\n }\n\n /**\n * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],\n * but performing a static call.\n *\n * _Available since v3.3._\n */\n function functionStaticCall(address target, bytes memory data) internal view returns (bytes memory) {\n return functionStaticCall(target, data, \"Address: low-level static call failed\");\n }\n\n /**\n * @dev Same as {xref-Address-functionCall-address-bytes-string-}[`functionCall`],\n * but performing a static call.\n *\n * _Available since v3.3._\n */\n function functionStaticCall(\n address target,\n bytes memory data,\n string memory errorMessage\n ) internal view returns (bytes memory) {\n (bool success, bytes memory returndata) = target.staticcall(data);\n return verifyCallResultFromTarget(target, success, returndata, errorMessage);\n }\n\n /**\n * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],\n * but performing a delegate call.\n *\n * _Available since v3.4._\n */\n function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) {\n return functionDelegateCall(target, data, \"Address: low-level delegate call failed\");\n }\n\n /**\n * @dev Same as {xref-Address-functionCall-address-bytes-string-}[`functionCall`],\n * but performing a delegate call.\n *\n * _Available since v3.4._\n */\n function functionDelegateCall(\n address target,\n bytes memory data,\n string memory errorMessage\n ) internal returns (bytes memory) {\n (bool success, bytes memory returndata) = target.delegatecall(data);\n return verifyCallResultFromTarget(target, success, returndata, errorMessage);\n }\n\n /**\n * @dev Tool to verify that a low level call to smart-contract was successful, and revert (either by bubbling\n * the revert reason or using the provided one) in case of unsuccessful call or if target was not a contract.\n *\n * _Available since v4.8._\n */\n function verifyCallResultFromTarget(\n address target,\n bool success,\n bytes memory returndata,\n string memory errorMessage\n ) internal view returns (bytes memory) {\n if (success) {\n if (returndata.length == 0) {\n // only check isContract if the call was successful and the return data is empty\n // otherwise we already know that it was a contract\n require(isContract(target), \"Address: call to non-contract\");\n }\n return returndata;\n } else {\n _revert(returndata, errorMessage);\n }\n }\n\n /**\n * @dev Tool to verify that a low level call was successful, and revert if it wasn't, either by bubbling the\n * revert reason or using the provided one.\n *\n * _Available since v4.3._\n */\n function verifyCallResult(\n bool success,\n bytes memory returndata,\n string memory errorMessage\n ) internal pure returns (bytes memory) {\n if (success) {\n return returndata;\n } else {\n _revert(returndata, errorMessage);\n }\n }\n\n function _revert(bytes memory returndata, string memory errorMessage) private pure {\n // Look for revert reason and bubble it up if present\n if (returndata.length > 0) {\n // The easiest way to bubble the revert reason is using memory via assembly\n /// @solidity memory-safe-assembly\n assembly {\n let returndata_size := mload(returndata)\n revert(add(32, returndata), returndata_size)\n }\n } else {\n revert(errorMessage);\n }\n }\n}\n" 12 | } 13 | }, 14 | "settings": { 15 | "optimizer": { 16 | "enabled": true, 17 | "runs": 2000 18 | }, 19 | "outputSelection": { 20 | "*": { 21 | "": [ 22 | "ast" 23 | ], 24 | "*": [ 25 | "abi", 26 | "metadata", 27 | "devdoc", 28 | "userdoc", 29 | "storageLayout", 30 | "evm.legacyAssembly", 31 | "evm.bytecode", 32 | "evm.deployedBytecode", 33 | "evm.methodIdentifiers", 34 | "evm.gasEstimates", 35 | "evm.assembly" 36 | ] 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /fundingvault/docs/TechnicalConcept.md: -------------------------------------------------------------------------------- 1 | # Technical Concept 2 | 3 | ## Description 4 | 5 | The FundingVault contract provides a way to distribute continuous limited amounts of funds to authorized entities. The distribution is time gated and a specific limit per grant is enforced (eg. 50k ETH per month). 6 | 7 | Grants are represented as ERC721 Tokens ("NFTs"), which can be created by contract managers based on a accepted application / approved funding need. 8 | 9 | The Grant NFT allows access to the granted amount of funds. Grantees are expected to store the NFT safely, but may transfer it to other wallets based on their needs. 10 | 11 | The fund request process is pull based. Funds can be requested from the FundingVault contract whenever needed. Whoever holds the Grant NFT is able to request the granted amount of funds. 12 | 13 | ## Contracts 14 | 15 | The FundingVault consists of three contracts: 16 | * `FundingVaultProxy` [0x610866c6089768dA95524bcc4cE7dB61eDa3931c](https://holesky.etherscan.io/address/0x610866c6089768da95524bcc4ce7db61eda3931c) 17 | Proxy contract that holds Funds and serves as entrypoint for FundingVault calls. 18 | * `FundingVaultV1` [0x93Af84598dda401de8c2ecC87052B8506E83D064](https://holesky.etherscan.io/address/0x93Af84598dda401de8c2ecC87052B8506E83D064) 19 | Implementation of the FundingVault logic (V1). 20 | * `FundingVaultToken` [0x97652A83CC29043fA9Be2781cc0038EBa70de911](https://holesky.etherscan.io/address/0x97652A83CC29043fA9Be2781cc0038EBa70de911) 21 | Token contract that provides a ERC721 token which gives permission to claim the allowed funds from the vault. 22 | 23 | ## Upgradability 24 | 25 | The FundingVault contracts are deployed in a way that allows upgrades & fixes of the core logic when needed. 26 | This also allows changing / extending the distribution logic if needed in future. 27 | 28 | ## User Groups 29 | There are 3 user groups (security roles) in the contract: 30 | * Grantee 31 | A wallet that holds a Grant NFT and is allowed to request funds (up to the granted limits). 32 | 33 | * Manager 34 | A wallet that is allowed to manage grants (create, update, delete, transfer) up to a certain limit. 35 | 36 | * Admin 37 | A (cold/multisig-)wallet with full access to all funds and emergency functions. 38 | May upgrade the contract or grant/revoke manager access. 39 | May create/update/transfer grants without any limitation. 40 | 41 | 42 | The manager & admin groups are represented by `AccessControl` roles within the contract. 43 | The grantee group is represented by the Grant NFT ownership, so whoever holds a Grant NFT is part of this group. 44 | 45 | ## Security Concept 46 | 47 | The FundingVault contract is intended to hold a large amount of funds over the lifetime of the network and drip it slowly over time to authorized entities. 48 | It should be ensured, that even in case of a security breach of one of the grantees or managers hot wallets, the majority of funds should be safely stored in the contract. 49 | It should be impossible to drain the contract completely in a short period of time just via a low secured user wallet. 50 | 51 | To achieve that, there are two limitation stategies: 52 | - Grants are limited to a specific amount of funds per time period. 53 | This is the most obvious limitation, which prevents requesting a big amounts of funds in a short time frame. 54 | In case of a security breach or if the grant NFT gets stolen, only the allowed amount of funds at the moment of the breach is lost. 55 | The NFT can be locked and transfered back to a verified wallet by contract managers afterwards. 56 | - Contract managers are limited to a specific amount of funds per time period too. 57 | This limit applies to the creation, modification and forceful transfer of grants. 58 | Actions with negative impact like deleting or locking a grant are not affected. 59 | The manager limit defaults to managing grants worth 100k ETH per month within one day, but can be adjusted by the contract admin when needed. 60 | Given the limit of 100k ETH/month, a manager may either: 61 | - create 2 grants with 50k ETH per month 62 | - create 1 grant with ~12.5k ETH per week 63 | - create 1 grant with 50k ETH per month and transfer another grant of 50k/month to a new wallet 64 | - delete or lock all grants 65 | 66 | ... or other combinations of actions, as long as they're not exceeding the limit. 67 | There is a lock threshold of 12h to avoid unnecesarry delay when managing multiple grants in one go. 68 | Given a limit of 100k/Month per day, that means locking only applies after managing grants worth 50k/Month. 69 | Beyond the threshold, the locking time gets applied lineary following the rules above. 70 | 71 | The manager limit enforces, that even in case of a manager wallet breach, the majority of funds in the Vault cannot be stolen immediatly. 72 | When exceeding the specified limit, managers are locked for a certain period of time before being allowed to create/update/transfer further grants ("cooldown"). 73 | Grants that exceed the manager limits on its own (eg. 200k/Month) cannot be created by managers themselves. Such big grants are expected to be created by the owner wallet, which is intended to be a high secured team multisig. 74 | 75 | 76 | ## Vault Contract 77 | 78 | ### Grantee Functions 79 | Grantee functions are accessible to all wallets that hold at least one grant NFT. 80 | The grantee functions provide different ways to claim the granted funds. 81 | 82 | | Identifer | Arguments | Description | 83 | |----------|------------|-------------| 84 | | claim<br>`0x379607f5` | amount | Claim `amount` wei from any grant the sender wallet holds and send it to the sender wallet.<br>Claim all available funds when amount = `0`.<br>Rejects if `amount` exceeds allowance or no funds are available | 85 | | claim<br>`0x503914db` | grantId<br>amount| Claim `amount` wei from grant with ID `grantId` and send it to the sender wallet.<br>Claim all available funds when amount = `0`<br>Rejects if `amount` exceeds allowance or no funds are available | 86 | | claimTo<br>`0x1fca9342` | amount<br/>target | Claim `amount` wei from any grant the sender wallet holds and send it to `target`.<br>Claim all available funds when amount = `0`<br>Rejects if `amount` exceeds allowance or no funds are available | 87 | | claimTo<br>`0x30e1198b` | grantId<br>amount<br/>target | Claim `amount` wei from grant with ID `grantId` and send it to `target`.<br>Claim all available funds when amount = `0`<br>Rejects if `amount` exceeds allowance or no funds are available | 88 | 89 | ### Manager Functions 90 | 91 | Manager functions are accessible to managers only (AccessControl role). 92 | Manager wallets are intended to be normal hot wallets of devops / otherwise permissioned users that are allowed to manage grants within specific limits. 93 | 94 | | Identifer | Arguments | Description | 95 | |----------|------------|-------------| 96 | | createGrant<br>`0xa92e6f2b` | addr<br>amount<br/>interval<br/>name | Create new grant with allowance of `amount` ETH per `interval` sec and send grant NFT to `addr`.<br>Rejects if allowance exceeds the manager limits | 97 | | lockGrant<br>`0x23d2dad7` | grantId<br>lockTime | Lock grant with ID `grantId` for `lockTime` seconds. This prevents the owner of the grant from claiming funds from the vault for the specified time. | 98 | | removeGrant<br>`0x362e7e13` | grantId | Remove grant with ID `grantId` and burn the NFT that represents the grant. | 99 | | renameGrant<br>`0x362e7e13` | grantId<br/>name | Update name for grant with ID `grantId`. | 100 | | transferGrant<br>`0xdc0533ef` | grantId<br/>target | Transfer the grant NFT that represents the grant with ID `grantId` to `target`.<br>Grantees are expected to transfer the NFT themselves when needed. This is intended to recover stolen NFTs.<br>Rejects if grant allowance exceeds the manager limits. 101 | | updateGrant<br>`0x8a1a14eb` | grantId<br/>amount<br/>interval | Update allowance of grant with ID `grantId` to `amount` ETH per `interval` sec.<br>Rejects if new allowance exceeds the manager limits. 102 | 103 | ### Owner Functions 104 | 105 | Owner functions are accessible to owners only (AccessControl role). 106 | Owner wallets are intended to be high security cold- or multisig wallets. 107 | Owners are allowed to manage grants with no limits and may call recovery functions / upgrade the contract. 108 | 109 | | Identifer | Arguments | Description | 110 | |----------|------------|-------------| 111 | | grantRole<br>`0x2f2ff15d` | role<br>account | AccessControl: Grant `role` to `account` | 112 | | revokeRole<br>`0xd547741f` | role<br>account | AccessControl: Revoke `role` from `account` | 113 | | rescueCall<br>`0x96dfe5de` | address<br>amount<br>data | Rescue function, do specified call | 114 | | setPaused<br>`0x16c38b3c` | pause | Disable claiming funds from the contract | 115 | | setClaimTransferLockTime<br>`0xa6a1cb4c` | pause | Set the number of seconds a claim gets locked for when the grant NFT gets transferred | 116 | | setManagerGrantLimits<br>`0x08cf0ebb` | amount<br>interval<br>cooldown<br>cooldownLock | Change manager limits. | 117 | | setProxyManager<br>`0xfe7f3505` | account | Set account that is allowed to upgrade the contract. | 118 | 119 | ### Grant structure 120 | 121 | Grants are stored as a struct within the Vault contract. 122 | There are 4 properties: 123 | * `claimLimit` 124 | Grant "amount" specified via `createGrant`/`updateGrant`. 125 | Defines the max amount of ETH that can accumulate in the grant over time. 126 | * `claimInterval` 127 | Grant "interval" specified via `createGrant`/`updateGrant`. 128 | Interval in seconds in which the full amount is available. 129 | * `claimTime` 130 | Specifies the current claim status. The status is stored as unix timestamp. 131 | It basically describes when the full granted amount has been claimed last. 132 | So, if `now() - claimTime` is 0, there are no funds available to claim. 133 | If `now() - claimTime` is >= `claimInterval`, the full granted amount (`claimLimit`) is available to claim. 134 | Each second represents a claimable balance of `claimLimit`/`claimInterval` ETH. 135 | The value of this property gets increased when claiming funds and may never exceed `now()` 136 | * `dustBalance` 137 | Dust balance that is avaiable to claim. 138 | This is used to handle claims with a amount smaller than `claimLimit`/`claimInterval` ETH. If a grantee claims ETH worth 1/2 seconds, `claimTime` is increased by 1 and the unclaimed rest (the remaining 1/2 sec) is added to the dustBalance. 139 | 140 | ### Claim Process 141 | 142 | The `_calculateClaim` function is the central piece of code that does the calculations for claiming funds via a grant. It is designed to handle claims from grantees accurately, while managing the balance available from a grant based on time. 143 | 144 | The claim calculation within `_calculateClaim` occurs in several steps: 145 | 146 | 1. **Initialization and Validation** 147 | - Retrieve the grant details from the `_grants` map using `grantId`. 148 | - Check that the grant's properties are valid (non-zero and logical). 149 | 150 | 2. **Handling Locked Grants** 151 | - If the grant is locked (`_grantClaimLock[grantId]` > current time), set output variables to reflect no funds can be claimed: `claimAmount` to 0, `newDustBalance` to the current `dustBalance`, and `usedTime` to 0. 152 | 153 | 3. **Calculating Available Funds** 154 | - Determine the `availableTime` since the last full claim by subtracting `claimTime` from the current time. 155 | - If `availableTime` exceeds `claimInterval`, reset it to `claimInterval` to limit the claim to the maximum replenished amount. Also, reset the `dustBalance` to 0 and adjust `baseClaimTime`. 156 | 157 | 4. **Using Dust Balance** 158 | - If there is a specific non-zero `requestAmount` and it is less than or equal to the `dustBalance`, use the dust balance to fulfill the claim entirely without affecting the `claimTime`. 159 | 160 | 5. **Calculating New Claims** 161 | - If the `requestAmount` is zero (indicating a request for all available funds) or if it exceeds the `dustBalance`, calculate the maximum claimable amount as `(claimLimit * availableTime / grant.claimInterval) + dustBalance`. 162 | - If a specific `requestAmount` is given and is less than the calculated maximum, adjust the `claimTime` based on the proportion of the `claimLimit` that the request represents. This involves: 163 | - Calculating the `usedTime` required to fulfill the request (minus the `dustBalance`). 164 | - Checking for rounding issues and adjusting the `usedTime` if necessary to ensure it accurately represents the funds being claimed. 165 | 166 | 6. **Setting Return Values** 167 | - Update `newClaimTime` by adding `usedTime` to `baseClaimTime`. 168 | - Set `claimAmount` to the lesser of the `requestAmount` or the calculated maximum. 169 | - Ensure that all calculations maintain logical consistency (e.g., `usedTime` does not exceed `availableTime`). 170 | -------------------------------------------------------------------------------- /fundingvault/contracts/FundingVaultV1.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.21; 3 | 4 | /* 5 | ################################################################## 6 | # Funding Vault # 7 | # # 8 | # This contract is used to distribute fund reserves to faucets # 9 | # or other projects that have a ongoing need for testnet funds. # 10 | # # 11 | # Vault contract: 0x610866c6089768dA95524bcc4cE7dB61eDa3931c # 12 | # # 13 | # see https://github.com/ethpandaops/fundingvault # 14 | ################################################################## 15 | */ 16 | 17 | import "@openzeppelin/contracts/access/AccessControl.sol"; 18 | import "@openzeppelin/contracts/security/Pausable.sol"; 19 | import "./ReentrancyGuard.sol"; 20 | import "./FundingVaultProxyStorage.sol"; 21 | import "./IFundingVaultToken.sol"; 22 | import "./IFundingVault.sol"; 23 | 24 | struct Grant { 25 | uint64 claimTime; 26 | uint64 claimInterval; 27 | uint128 claimLimit; 28 | uint256 dustBalance; 29 | } 30 | 31 | contract FundingVaultStorage { 32 | // slot 0x05 33 | address internal _vaultTokenAddr; 34 | uint64 internal _grantIdCounter; 35 | uint32 internal _claimTransferLockTime; 36 | 37 | // slot 0x06 38 | uint128 internal _managerLimitAmount; 39 | uint64 internal _managerLimitInterval; 40 | uint32 internal _managerGrantCooldown; 41 | uint32 internal _managerGrantCooldownLock; 42 | 43 | // slot 0x07 44 | mapping(uint64 => Grant) internal _grants; 45 | 46 | // slot 0x08 47 | mapping(uint64 => uint64) internal _grantClaimLock; 48 | 49 | // slot 0x09 50 | mapping(address => uint64) internal _managerCooldown; 51 | 52 | // slot 0x0a 53 | mapping(uint64 => uint256) internal _grantTotalClaimed; 54 | 55 | // slot 0x0b 56 | mapping(uint64 => bytes32) internal _grantNames; 57 | } 58 | 59 | contract FundingVaultV1 is 60 | FundingVaultProxyStorage, // 0x00 - 0x01 61 | AccessControl, // 0x02 62 | Pausable, // 0x03 63 | ReentrancyGuard, // 0x04 64 | FundingVaultStorage, // 0x05 - 0x0b 65 | IFundingVault 66 | { 67 | bytes32 public constant GRANT_MANAGER_ROLE = keccak256("GRANT_MANAGER_ROLE"); 68 | 69 | event GrantLock(uint64 indexed grantId, uint64 lockTime, uint64 lockTimeout); 70 | event GrantUpdate(uint64 indexed grantId, uint128 amount, uint64 interval); 71 | event GrantClaim(uint64 indexed grantId, address indexed to, uint256 amount, uint64 grantTimeUsed); 72 | 73 | receive() external payable { 74 | } 75 | 76 | function initialize(address tokenAddr) public { 77 | require(tokenAddr != address(0), "tokenAddr must not be 0"); 78 | require(_reentrancyStatus == 0 && _grantIdCounter == 0, "already initialized"); 79 | require(_manager == _msgSender(), "access denied"); 80 | _grantRole(DEFAULT_ADMIN_ROLE, _manager); 81 | _reentrancyStatus = 1; 82 | _vaultTokenAddr = tokenAddr; 83 | _grantIdCounter = 1; 84 | _claimTransferLockTime = 600; 85 | _managerLimitAmount = 100000; 86 | _managerLimitInterval = 2592000; 87 | _managerGrantCooldown = 86400; 88 | _managerGrantCooldownLock = 43200; 89 | } 90 | 91 | 92 | //## Admin configuration / rescue functions 93 | 94 | function rescueCall(address addr, uint256 amount, bytes calldata data) public onlyRole(DEFAULT_ADMIN_ROLE) { 95 | require(addr != address(0), "addr must not be 0"); 96 | 97 | uint balance = address(this).balance; 98 | require(balance >= amount, "amount exceeds wallet balance"); 99 | 100 | (bool sent, ) = payable(addr).call{value: amount}(data); 101 | require(sent, "call failed"); 102 | } 103 | 104 | function setPaused(bool paused) public onlyRole(DEFAULT_ADMIN_ROLE) { 105 | if(paused) { 106 | _pause(); 107 | } else { 108 | _unpause(); 109 | } 110 | } 111 | 112 | function setProxyManager(address manager) public onlyRole(DEFAULT_ADMIN_ROLE) { 113 | require(manager != address(0), "manager must not be 0"); 114 | _manager = manager; 115 | } 116 | 117 | function setClaimTransferLockTime(uint32 lockTime) public onlyRole(DEFAULT_ADMIN_ROLE) { 118 | _claimTransferLockTime = lockTime; 119 | } 120 | 121 | function setManagerGrantLimits(uint128 amount, uint64 interval, uint32 cooldown, uint32 cooldownLock) public onlyRole(DEFAULT_ADMIN_ROLE) { 122 | _managerLimitAmount = amount; 123 | _managerLimitInterval = interval; 124 | _managerGrantCooldown = cooldown; 125 | _managerGrantCooldownLock = cooldownLock; 126 | } 127 | 128 | 129 | //## Internal helper functions 130 | 131 | function _ownerOf(uint64 tokenId) internal view returns (address) { 132 | return IFundingVaultToken(_vaultTokenAddr).ownerOf(tokenId); 133 | } 134 | 135 | function _getTime() internal view returns (uint64) { 136 | return uint64(block.timestamp); 137 | } 138 | 139 | /* 140 | The _calculateClaim function is the central piece of code that does the calculations for claiming funds via a grant. 141 | Arguments: 142 | grantId - the grant id the sender likes to claim from 143 | requestAmount - the desired amount of funds the sender likes to claim (0 to claim all available) 144 | Return Values: 145 | claimAmount - the amount of funds for payout, smaller or equal to requestedAmount, max available if requestAmount is 0 146 | newClaimTime - the new claimTime, must be set to the grant struct if claimAmount is payed out 147 | newDustBalance - the new dustBalance, must be set to the grant struct if claimAmount is payed out 148 | usedTime - the used claim time to fulfil the request (more informative and for debugging) 149 | */ 150 | function _calculateClaim(uint64 grantId, uint256 requestAmount) public view 151 | returns (uint256 claimAmount, uint64 newClaimTime, uint256 newDustBalance, uint64 usedTime) { 152 | Grant memory grant = _grants[grantId]; 153 | require(grant.claimInterval > 0 && grant.claimLimit > 0 && grant.claimTime > 0, "invalid grant"); 154 | 155 | uint256 claimLimit = grant.claimLimit * 1 ether; 156 | if(requestAmount > claimLimit) { 157 | requestAmount = claimLimit; 158 | } 159 | 160 | uint64 time = _getTime(); 161 | if(_grantClaimLock[grantId] > time) { 162 | // grant locked 163 | newClaimTime = grant.claimTime; 164 | usedTime = 0; 165 | claimAmount = 0; 166 | newDustBalance = grant.dustBalance; 167 | } 168 | else { 169 | uint64 baseClaimTime = grant.claimTime; 170 | uint64 availableTime = time - baseClaimTime; 171 | uint256 dustBalance = grant.dustBalance; 172 | if(availableTime > grant.claimInterval) { 173 | // available time exceeds interval 174 | // the sender claimed less than granted, the unclaimed amount is no longer available 175 | availableTime = grant.claimInterval; 176 | baseClaimTime = time - grant.claimInterval; 177 | dustBalance = 0; 178 | } 179 | 180 | if(requestAmount != 0 && requestAmount <= dustBalance) { 181 | // take from dust balance 182 | newClaimTime = baseClaimTime; 183 | usedTime = 0; 184 | claimAmount = requestAmount; 185 | newDustBalance = dustBalance - requestAmount; 186 | } 187 | else { 188 | // get max claimable amount 189 | claimAmount = (claimLimit * availableTime / grant.claimInterval) + dustBalance; 190 | 191 | if(requestAmount != 0 && requestAmount < claimAmount) { 192 | // sender requested less than available, "partial" claim 193 | uint256 requestClaimAmount = requestAmount - dustBalance; 194 | usedTime = uint64(requestClaimAmount * grant.claimInterval / claimLimit); 195 | if(usedTime * claimLimit / grant.claimInterval < requestClaimAmount) { 196 | usedTime++; // round up if there is a rounding gap in ETH amount 197 | newDustBalance = (usedTime * claimLimit / grant.claimInterval) - requestClaimAmount; 198 | } 199 | else { 200 | newDustBalance = 0; 201 | } 202 | require(usedTime <= availableTime, "calculation error: usedTime > availableTime"); 203 | 204 | newClaimTime = baseClaimTime + usedTime; 205 | claimAmount = requestAmount; 206 | } 207 | else { 208 | // sender requested all available funds 209 | usedTime = availableTime; 210 | newClaimTime = time; 211 | newDustBalance = 0; 212 | } 213 | } 214 | } 215 | } 216 | 217 | 218 | //## Public view functions 219 | 220 | function getVaultToken() public view returns (address) { 221 | return _vaultTokenAddr; 222 | } 223 | 224 | function getGrants() public view returns (Grant[] memory) { 225 | IFundingVaultToken vaultToken = IFundingVaultToken(_vaultTokenAddr); 226 | uint256 grantCount = vaultToken.totalSupply(); 227 | Grant[] memory grants = new Grant[](grantCount); 228 | for(uint256 grantIdx = 0; grantIdx < grantCount; grantIdx++) { 229 | uint64 grantId = uint64(vaultToken.tokenByIndex(grantIdx)); 230 | grants[grantIdx] = _grants[grantId]; 231 | } 232 | return grants; 233 | } 234 | 235 | function getGrant(uint64 grantId) public view returns (Grant memory) { 236 | require(_grants[grantId].claimTime > 0, "grant not found"); 237 | return _grants[grantId]; 238 | } 239 | 240 | function getGrantName(uint64 grantId) public view returns (bytes32) { 241 | require(_grants[grantId].claimTime > 0, "grant not found"); 242 | return _grantNames[grantId]; 243 | } 244 | 245 | function getGrantTotalClaimed(uint64 grantId) public view returns (uint256) { 246 | return _grantTotalClaimed[grantId]; 247 | } 248 | 249 | function getGrantLockTime(uint64 grantId) public view returns (uint64) { 250 | require(_grants[grantId].claimTime > 0, "grant not found"); 251 | if(_grantClaimLock[grantId] > _getTime()) { 252 | return _grantClaimLock[grantId] - _getTime(); 253 | } 254 | else { 255 | return 0; 256 | } 257 | } 258 | 259 | function getClaimableBalance() public view returns (uint256) { 260 | uint256 claimableAmount = 0; 261 | IFundingVaultToken vaultToken = IFundingVaultToken(_vaultTokenAddr); 262 | 263 | uint64 grantCount = uint64(vaultToken.balanceOf(_msgSender())); 264 | for(uint64 grantIdx = 0; grantIdx < grantCount; grantIdx++) { 265 | uint64 grantId = uint64(vaultToken.tokenOfOwnerByIndex(_msgSender(), grantIdx)); 266 | claimableAmount += _claimableBalance(grantId); 267 | } 268 | return claimableAmount; 269 | } 270 | 271 | function getClaimableBalance(uint64 grantId) public view returns (uint256) { 272 | require(_grants[grantId].claimTime > 0, "grant not found"); 273 | return _claimableBalance(grantId); 274 | } 275 | 276 | function _claimableBalance(uint64 grantId) internal view returns (uint256) { 277 | (uint256 claimAmount, , , ) = _calculateClaim(grantId, 0); 278 | return claimAmount; 279 | } 280 | 281 | function getManagerCooldown(address manager) public view returns (uint64) { 282 | if(_managerCooldown[manager] <= _getTime()) { 283 | return 0; 284 | } 285 | return _managerCooldown[manager] - _getTime(); 286 | } 287 | 288 | function getManagerGrantLimits() public view returns (uint128, uint64, uint32, uint32) { 289 | return ( 290 | _managerLimitAmount, 291 | _managerLimitInterval, 292 | _managerGrantCooldown, 293 | _managerGrantCooldownLock 294 | ); 295 | } 296 | 297 | //## Grant managemnet functions (Grant Manager) 298 | 299 | function createGrant(address addr, uint128 amount, uint64 interval, bytes32 name) public onlyRole(GRANT_MANAGER_ROLE) nonReentrant { 300 | require(amount > 0 && interval > 0, "invalid grant"); 301 | require(interval < _getTime(), "interval too big"); 302 | require(addr != address(0), "addr must not be 0"); 303 | uint256 grantQuota = uint256(amount) * 1 ether / interval; 304 | uint256 managerQuota = uint256(_managerLimitAmount) * 1 ether / _managerLimitInterval; 305 | 306 | if(!hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 307 | _requireNotPaused(); 308 | 309 | if(interval > _managerLimitInterval) { 310 | // special case, if a grant with an interval bigger than the manager limit interval is created 311 | // increase the grantQuota as if the grant would have been created with the manager limit interval 312 | // this avoids managers from exploiting the contract by creating multiple grants with extremely high intervals 313 | grantQuota = uint256(amount) * 1 ether / _managerLimitInterval; 314 | } 315 | 316 | // check if granted amount exceeds manager limits 317 | require(amount <= _managerLimitAmount, "amount exceeds manager limits"); 318 | require(grantQuota <= managerQuota, "quota exceeds manager limits"); 319 | require(_managerCooldown[_msgSender()] < _getTime() + _managerGrantCooldownLock, "manager cooldown"); 320 | } 321 | if(_managerCooldown[_msgSender()] < _getTime()) { 322 | _managerCooldown[_msgSender()] = _getTime(); 323 | } 324 | _managerCooldown[_msgSender()] += uint64(_managerGrantCooldown * grantQuota / managerQuota) + 1; 325 | 326 | uint64 grantId = _grantIdCounter++; 327 | _grants[grantId] = Grant({ 328 | claimTime: _getTime() - interval, 329 | claimInterval: interval, 330 | claimLimit: amount, 331 | dustBalance: 0 332 | }); 333 | _grantNames[grantId] = name; 334 | IFundingVaultToken(_vaultTokenAddr).tokenUpdate(grantId, addr); 335 | 336 | emit GrantUpdate(grantId, amount, interval); 337 | } 338 | 339 | function updateGrant(uint64 grantId, uint128 amount, uint64 interval) public onlyRole(GRANT_MANAGER_ROLE) nonReentrant { 340 | require(_grants[grantId].claimTime > 0, "grant not found"); 341 | require(amount > 0 && interval > 0, "invalid grant"); 342 | 343 | uint256 oldQuota = uint256(_grants[grantId].claimLimit) * 1 ether / _grants[grantId].claimInterval; 344 | uint256 newQuota = uint256(amount) * 1 ether / interval; 345 | uint256 managerQuota = uint256(_managerLimitAmount) * 1 ether / _managerLimitInterval; 346 | bool isIncrease = newQuota > oldQuota; 347 | 348 | if(!hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 349 | _requireNotPaused(); 350 | // check if granted amount exceeds manager limits 351 | require(amount <= _managerLimitAmount, "amount exceeds manager limits"); 352 | require(newQuota <= managerQuota, "quota exceeds manager limits"); 353 | 354 | if(isIncrease) { 355 | require(_managerCooldown[_msgSender()] < _getTime() + _managerGrantCooldownLock, "manager cooldown"); 356 | } 357 | } 358 | if(isIncrease) { 359 | if(_managerCooldown[_msgSender()] < _getTime()) { 360 | _managerCooldown[_msgSender()] = _getTime(); 361 | } 362 | _managerCooldown[_msgSender()] += uint64(_managerGrantCooldown * (newQuota - oldQuota) / managerQuota) + 1; 363 | } 364 | 365 | _grants[grantId].claimInterval = interval; 366 | _grants[grantId].claimLimit = amount; 367 | 368 | emit GrantUpdate(grantId, amount, interval); 369 | } 370 | 371 | function transferGrant(uint64 grantId, address addr) public onlyRole(GRANT_MANAGER_ROLE) nonReentrant { 372 | require(_grants[grantId].claimTime > 0, "grant not found"); 373 | require(addr != address(0), "addr must not be 0"); 374 | 375 | uint256 grantQuota = uint256(_grants[grantId].claimLimit) * 1 ether / _grants[grantId].claimInterval; 376 | uint256 managerQuota = uint256(_managerLimitAmount) * 1 ether / _managerLimitInterval; 377 | 378 | if(!hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 379 | _requireNotPaused(); 380 | // check if grant quota exceeds manager limits 381 | require(_grants[grantId].claimLimit <= _managerLimitAmount, "quota exceeds manager limits"); 382 | require(grantQuota <= managerQuota, "quota exceeds manager limits"); 383 | require(_managerCooldown[_msgSender()] < _getTime() + _managerGrantCooldownLock, "manager cooldown"); 384 | } 385 | if(_managerCooldown[_msgSender()] < _getTime()) { 386 | _managerCooldown[_msgSender()] = _getTime(); 387 | } 388 | _managerCooldown[_msgSender()] += uint64(_managerGrantCooldown * grantQuota / managerQuota) + 1; 389 | 390 | IFundingVaultToken(_vaultTokenAddr).tokenUpdate(grantId, addr); 391 | } 392 | 393 | function removeGrant(uint64 grantId) public onlyRole(GRANT_MANAGER_ROLE) nonReentrant { 394 | require(_grants[grantId].claimTime > 0, "grant not found"); 395 | 396 | if(!hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 397 | _requireNotPaused(); 398 | } 399 | 400 | IFundingVaultToken(_vaultTokenAddr).tokenUpdate(grantId, address(0)); 401 | delete _grants[grantId]; 402 | } 403 | 404 | function renameGrant(uint64 grantId, bytes32 name) public onlyRole(GRANT_MANAGER_ROLE) nonReentrant { 405 | require(_grants[grantId].claimTime > 0, "grant not found"); 406 | 407 | if(!hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 408 | _requireNotPaused(); 409 | } 410 | 411 | _grantNames[grantId] = name; 412 | } 413 | 414 | function lockGrant(uint64 grantId, uint64 lockTime) public nonReentrant { 415 | require(_grants[grantId].claimTime > 0, "grant not found"); 416 | require( 417 | _msgSender() == _ownerOf(grantId) || 418 | hasRole(GRANT_MANAGER_ROLE, _msgSender()) 419 | , "not grant owner or manager"); 420 | 421 | if(!hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 422 | _requireNotPaused(); 423 | } 424 | 425 | _lockGrant(grantId, lockTime); 426 | } 427 | 428 | function notifyGrantTransfer(uint64 grantId) public { 429 | require(_msgSender() == _vaultTokenAddr, "not token contract"); 430 | _lockGrant(grantId, _claimTransferLockTime); 431 | } 432 | 433 | function _lockGrant(uint64 grantId, uint64 lockTime) internal { 434 | uint64 lockTimeout = _getTime() + lockTime; 435 | if(lockTimeout > _grantClaimLock[grantId] || hasRole(DEFAULT_ADMIN_ROLE, _msgSender())) { 436 | _grantClaimLock[grantId] = lockTimeout; 437 | } 438 | else { 439 | lockTime = 0; 440 | lockTimeout = _grantClaimLock[grantId]; 441 | } 442 | emit GrantLock(grantId, lockTime, lockTimeout); 443 | } 444 | 445 | 446 | //## Public claim functions 447 | 448 | function claim(uint256 amount) public whenNotPaused nonReentrant returns (uint256) { 449 | uint256 claimAmount = _claimFrom(_msgSender(), amount, _msgSender()); 450 | if(amount > 0) { 451 | require(claimAmount == amount, "claim failed"); 452 | } 453 | else { 454 | require(claimAmount > 0, "claim failed"); 455 | } 456 | return claimAmount; 457 | } 458 | 459 | function claim(uint64 grantId, uint256 amount) public whenNotPaused nonReentrant returns (uint256) { 460 | require(_grants[grantId].claimTime > 0, "grant not found"); 461 | require(_ownerOf(grantId) == _msgSender(), "not owner of this grant"); 462 | 463 | uint256 claimAmount = _claim(grantId, amount, _msgSender()); 464 | if(amount > 0) { 465 | require(claimAmount == amount, "claim failed"); 466 | } 467 | else { 468 | require(claimAmount > 0, "claim failed"); 469 | } 470 | return claimAmount; 471 | } 472 | 473 | function claimTo(uint256 amount, address target) public whenNotPaused nonReentrant returns (uint256) { 474 | require(target != address(0), "target must not be 0"); 475 | 476 | uint256 claimAmount = _claimFrom(_msgSender(), amount, target); 477 | if(amount > 0) { 478 | require(claimAmount == amount, "claim failed"); 479 | } 480 | else { 481 | require(claimAmount > 0, "claim failed"); 482 | } 483 | return claimAmount; 484 | } 485 | 486 | function claimTo(uint64 grantId, uint256 amount, address target) public whenNotPaused nonReentrant returns (uint256) { 487 | require(_grants[grantId].claimTime > 0, "grant not found"); 488 | require(_ownerOf(grantId) == _msgSender(), "not owner of this grant"); 489 | require(target != address(0), "target must not be 0"); 490 | 491 | uint256 claimAmount = _claim(grantId, amount, target); 492 | if(amount > 0) { 493 | require(claimAmount == amount, "claim failed"); 494 | } 495 | else { 496 | require(claimAmount > 0, "claim failed"); 497 | } 498 | return claimAmount; 499 | } 500 | 501 | function _claimFrom(address owner, uint256 amount, address target) internal returns (uint256) { 502 | uint256 claimAmount = 0; 503 | IFundingVaultToken vaultToken = IFundingVaultToken(_vaultTokenAddr); 504 | 505 | uint64 grantCount = uint64(vaultToken.balanceOf(owner)); 506 | for(uint64 grantIdx = 0; grantIdx < grantCount; grantIdx++) { 507 | uint64 grantId = uint64(vaultToken.tokenOfOwnerByIndex(owner, grantIdx)); 508 | uint256 claimed = _claim(grantId, amount, target); 509 | claimAmount += claimed; 510 | if(amount > 0) { 511 | if(amount == claimed) { 512 | break; 513 | } 514 | else { 515 | amount -= claimed; 516 | } 517 | } 518 | } 519 | return claimAmount; 520 | } 521 | 522 | function _claim(uint64 grantId, uint256 amount, address target) internal returns (uint256) { 523 | (uint256 claimAmount, uint64 newClaimTime, uint256 newDustBalance, uint64 usedClaimTime) = _calculateClaim(grantId, amount); 524 | if(claimAmount == 0) { 525 | return 0; 526 | } 527 | 528 | // update grant struct 529 | _grants[grantId].claimTime = newClaimTime; 530 | _grants[grantId].dustBalance = newDustBalance; 531 | _grantTotalClaimed[grantId] += claimAmount; 532 | 533 | // send claim amount to target 534 | (bool sent, ) = payable(target).call{value: claimAmount}(""); 535 | require(sent, "failed to send ether"); 536 | 537 | // emit claim event 538 | emit GrantClaim(grantId, target, claimAmount, usedClaimTime); 539 | 540 | return claimAmount; 541 | } 542 | 543 | } 544 | -------------------------------------------------------------------------------- /webui/src/abi/FundingVault.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "anonymous": false, 4 | "inputs": [ 5 | { 6 | "indexed": true, 7 | "internalType": "uint64", 8 | "name": "grantId", 9 | "type": "uint64" 10 | }, 11 | { 12 | "indexed": true, 13 | "internalType": "address", 14 | "name": "to", 15 | "type": "address" 16 | }, 17 | { 18 | "indexed": false, 19 | "internalType": "uint256", 20 | "name": "amount", 21 | "type": "uint256" 22 | }, 23 | { 24 | "indexed": false, 25 | "internalType": "uint64", 26 | "name": "grantTimeUsed", 27 | "type": "uint64" 28 | } 29 | ], 30 | "name": "GrantClaim", 31 | "type": "event" 32 | }, 33 | { 34 | "anonymous": false, 35 | "inputs": [ 36 | { 37 | "indexed": true, 38 | "internalType": "uint64", 39 | "name": "grantId", 40 | "type": "uint64" 41 | }, 42 | { 43 | "indexed": false, 44 | "internalType": "uint64", 45 | "name": "lockTime", 46 | "type": "uint64" 47 | }, 48 | { 49 | "indexed": false, 50 | "internalType": "uint64", 51 | "name": "lockTimeout", 52 | "type": "uint64" 53 | } 54 | ], 55 | "name": "GrantLock", 56 | "type": "event" 57 | }, 58 | { 59 | "anonymous": false, 60 | "inputs": [ 61 | { 62 | "indexed": true, 63 | "internalType": "uint64", 64 | "name": "grantId", 65 | "type": "uint64" 66 | }, 67 | { 68 | "indexed": false, 69 | "internalType": "uint128", 70 | "name": "amount", 71 | "type": "uint128" 72 | }, 73 | { 74 | "indexed": false, 75 | "internalType": "uint64", 76 | "name": "interval", 77 | "type": "uint64" 78 | } 79 | ], 80 | "name": "GrantUpdate", 81 | "type": "event" 82 | }, 83 | { 84 | "anonymous": false, 85 | "inputs": [ 86 | { 87 | "indexed": false, 88 | "internalType": "address", 89 | "name": "account", 90 | "type": "address" 91 | } 92 | ], 93 | "name": "Paused", 94 | "type": "event" 95 | }, 96 | { 97 | "anonymous": false, 98 | "inputs": [ 99 | { 100 | "indexed": true, 101 | "internalType": "bytes32", 102 | "name": "role", 103 | "type": "bytes32" 104 | }, 105 | { 106 | "indexed": true, 107 | "internalType": "bytes32", 108 | "name": "previousAdminRole", 109 | "type": "bytes32" 110 | }, 111 | { 112 | "indexed": true, 113 | "internalType": "bytes32", 114 | "name": "newAdminRole", 115 | "type": "bytes32" 116 | } 117 | ], 118 | "name": "RoleAdminChanged", 119 | "type": "event" 120 | }, 121 | { 122 | "anonymous": false, 123 | "inputs": [ 124 | { 125 | "indexed": true, 126 | "internalType": "bytes32", 127 | "name": "role", 128 | "type": "bytes32" 129 | }, 130 | { 131 | "indexed": true, 132 | "internalType": "address", 133 | "name": "account", 134 | "type": "address" 135 | }, 136 | { 137 | "indexed": true, 138 | "internalType": "address", 139 | "name": "sender", 140 | "type": "address" 141 | } 142 | ], 143 | "name": "RoleGranted", 144 | "type": "event" 145 | }, 146 | { 147 | "anonymous": false, 148 | "inputs": [ 149 | { 150 | "indexed": true, 151 | "internalType": "bytes32", 152 | "name": "role", 153 | "type": "bytes32" 154 | }, 155 | { 156 | "indexed": true, 157 | "internalType": "address", 158 | "name": "account", 159 | "type": "address" 160 | }, 161 | { 162 | "indexed": true, 163 | "internalType": "address", 164 | "name": "sender", 165 | "type": "address" 166 | } 167 | ], 168 | "name": "RoleRevoked", 169 | "type": "event" 170 | }, 171 | { 172 | "anonymous": false, 173 | "inputs": [ 174 | { 175 | "indexed": false, 176 | "internalType": "address", 177 | "name": "account", 178 | "type": "address" 179 | } 180 | ], 181 | "name": "Unpaused", 182 | "type": "event" 183 | }, 184 | { 185 | "inputs": [], 186 | "name": "DEFAULT_ADMIN_ROLE", 187 | "outputs": [ 188 | { 189 | "internalType": "bytes32", 190 | "name": "", 191 | "type": "bytes32" 192 | } 193 | ], 194 | "stateMutability": "view", 195 | "type": "function" 196 | }, 197 | { 198 | "inputs": [], 199 | "name": "GRANT_MANAGER_ROLE", 200 | "outputs": [ 201 | { 202 | "internalType": "bytes32", 203 | "name": "", 204 | "type": "bytes32" 205 | } 206 | ], 207 | "stateMutability": "view", 208 | "type": "function" 209 | }, 210 | { 211 | "inputs": [ 212 | { 213 | "internalType": "uint64", 214 | "name": "grantId", 215 | "type": "uint64" 216 | }, 217 | { 218 | "internalType": "uint256", 219 | "name": "requestAmount", 220 | "type": "uint256" 221 | } 222 | ], 223 | "name": "_calculateClaim", 224 | "outputs": [ 225 | { 226 | "internalType": "uint256", 227 | "name": "claimAmount", 228 | "type": "uint256" 229 | }, 230 | { 231 | "internalType": "uint64", 232 | "name": "newClaimTime", 233 | "type": "uint64" 234 | }, 235 | { 236 | "internalType": "uint256", 237 | "name": "newDustBalance", 238 | "type": "uint256" 239 | }, 240 | { 241 | "internalType": "uint64", 242 | "name": "usedTime", 243 | "type": "uint64" 244 | } 245 | ], 246 | "stateMutability": "view", 247 | "type": "function" 248 | }, 249 | { 250 | "inputs": [ 251 | { 252 | "internalType": "uint256", 253 | "name": "amount", 254 | "type": "uint256" 255 | } 256 | ], 257 | "name": "claim", 258 | "outputs": [ 259 | { 260 | "internalType": "uint256", 261 | "name": "", 262 | "type": "uint256" 263 | } 264 | ], 265 | "stateMutability": "nonpayable", 266 | "type": "function" 267 | }, 268 | { 269 | "inputs": [ 270 | { 271 | "internalType": "uint64", 272 | "name": "grantId", 273 | "type": "uint64" 274 | }, 275 | { 276 | "internalType": "uint256", 277 | "name": "amount", 278 | "type": "uint256" 279 | } 280 | ], 281 | "name": "claim", 282 | "outputs": [ 283 | { 284 | "internalType": "uint256", 285 | "name": "", 286 | "type": "uint256" 287 | } 288 | ], 289 | "stateMutability": "nonpayable", 290 | "type": "function" 291 | }, 292 | { 293 | "inputs": [ 294 | { 295 | "internalType": "uint64", 296 | "name": "grantId", 297 | "type": "uint64" 298 | }, 299 | { 300 | "internalType": "uint256", 301 | "name": "amount", 302 | "type": "uint256" 303 | }, 304 | { 305 | "internalType": "address", 306 | "name": "target", 307 | "type": "address" 308 | } 309 | ], 310 | "name": "claimTo", 311 | "outputs": [ 312 | { 313 | "internalType": "uint256", 314 | "name": "", 315 | "type": "uint256" 316 | } 317 | ], 318 | "stateMutability": "nonpayable", 319 | "type": "function" 320 | }, 321 | { 322 | "inputs": [ 323 | { 324 | "internalType": "uint256", 325 | "name": "amount", 326 | "type": "uint256" 327 | }, 328 | { 329 | "internalType": "address", 330 | "name": "target", 331 | "type": "address" 332 | } 333 | ], 334 | "name": "claimTo", 335 | "outputs": [ 336 | { 337 | "internalType": "uint256", 338 | "name": "", 339 | "type": "uint256" 340 | } 341 | ], 342 | "stateMutability": "nonpayable", 343 | "type": "function" 344 | }, 345 | { 346 | "inputs": [ 347 | { 348 | "internalType": "address", 349 | "name": "addr", 350 | "type": "address" 351 | }, 352 | { 353 | "internalType": "uint128", 354 | "name": "amount", 355 | "type": "uint128" 356 | }, 357 | { 358 | "internalType": "uint64", 359 | "name": "interval", 360 | "type": "uint64" 361 | }, 362 | { 363 | "internalType": "bytes32", 364 | "name": "name", 365 | "type": "bytes32" 366 | } 367 | ], 368 | "name": "createGrant", 369 | "outputs": [], 370 | "stateMutability": "nonpayable", 371 | "type": "function" 372 | }, 373 | { 374 | "inputs": [ 375 | { 376 | "internalType": "uint64", 377 | "name": "grantId", 378 | "type": "uint64" 379 | } 380 | ], 381 | "name": "getClaimableBalance", 382 | "outputs": [ 383 | { 384 | "internalType": "uint256", 385 | "name": "", 386 | "type": "uint256" 387 | } 388 | ], 389 | "stateMutability": "view", 390 | "type": "function" 391 | }, 392 | { 393 | "inputs": [], 394 | "name": "getClaimableBalance", 395 | "outputs": [ 396 | { 397 | "internalType": "uint256", 398 | "name": "", 399 | "type": "uint256" 400 | } 401 | ], 402 | "stateMutability": "view", 403 | "type": "function" 404 | }, 405 | { 406 | "inputs": [ 407 | { 408 | "internalType": "uint64", 409 | "name": "grantId", 410 | "type": "uint64" 411 | } 412 | ], 413 | "name": "getGrant", 414 | "outputs": [ 415 | { 416 | "components": [ 417 | { 418 | "internalType": "uint64", 419 | "name": "claimTime", 420 | "type": "uint64" 421 | }, 422 | { 423 | "internalType": "uint64", 424 | "name": "claimInterval", 425 | "type": "uint64" 426 | }, 427 | { 428 | "internalType": "uint128", 429 | "name": "claimLimit", 430 | "type": "uint128" 431 | }, 432 | { 433 | "internalType": "uint256", 434 | "name": "dustBalance", 435 | "type": "uint256" 436 | } 437 | ], 438 | "internalType": "struct Grant", 439 | "name": "", 440 | "type": "tuple" 441 | } 442 | ], 443 | "stateMutability": "view", 444 | "type": "function" 445 | }, 446 | { 447 | "inputs": [ 448 | { 449 | "internalType": "uint32", 450 | "name": "grantId", 451 | "type": "uint32" 452 | } 453 | ], 454 | "name": "getGrantLockTime", 455 | "outputs": [ 456 | { 457 | "internalType": "uint64", 458 | "name": "", 459 | "type": "uint64" 460 | } 461 | ], 462 | "stateMutability": "view", 463 | "type": "function" 464 | }, 465 | { 466 | "inputs": [ 467 | { 468 | "internalType": "uint64", 469 | "name": "grantId", 470 | "type": "uint64" 471 | } 472 | ], 473 | "name": "getGrantName", 474 | "outputs": [ 475 | { 476 | "internalType": "bytes32", 477 | "name": "", 478 | "type": "bytes32" 479 | } 480 | ], 481 | "stateMutability": "view", 482 | "type": "function" 483 | }, 484 | { 485 | "inputs": [ 486 | { 487 | "internalType": "uint64", 488 | "name": "grantId", 489 | "type": "uint64" 490 | } 491 | ], 492 | "name": "getGrantTotalClaimed", 493 | "outputs": [ 494 | { 495 | "internalType": "uint256", 496 | "name": "", 497 | "type": "uint256" 498 | } 499 | ], 500 | "stateMutability": "view", 501 | "type": "function" 502 | }, 503 | { 504 | "inputs": [], 505 | "name": "getGrants", 506 | "outputs": [ 507 | { 508 | "components": [ 509 | { 510 | "internalType": "uint64", 511 | "name": "claimTime", 512 | "type": "uint64" 513 | }, 514 | { 515 | "internalType": "uint64", 516 | "name": "claimInterval", 517 | "type": "uint64" 518 | }, 519 | { 520 | "internalType": "uint128", 521 | "name": "claimLimit", 522 | "type": "uint128" 523 | }, 524 | { 525 | "internalType": "uint256", 526 | "name": "dustBalance", 527 | "type": "uint256" 528 | } 529 | ], 530 | "internalType": "struct Grant[]", 531 | "name": "", 532 | "type": "tuple[]" 533 | } 534 | ], 535 | "stateMutability": "view", 536 | "type": "function" 537 | }, 538 | { 539 | "inputs": [ 540 | { 541 | "internalType": "address", 542 | "name": "manager", 543 | "type": "address" 544 | } 545 | ], 546 | "name": "getManagerCooldown", 547 | "outputs": [ 548 | { 549 | "internalType": "uint64", 550 | "name": "", 551 | "type": "uint64" 552 | } 553 | ], 554 | "stateMutability": "view", 555 | "type": "function" 556 | }, 557 | { 558 | "inputs": [], 559 | "name": "getManagerGrantLimits", 560 | "outputs": [ 561 | { 562 | "internalType": "uint128", 563 | "name": "", 564 | "type": "uint128" 565 | }, 566 | { 567 | "internalType": "uint64", 568 | "name": "", 569 | "type": "uint64" 570 | }, 571 | { 572 | "internalType": "uint32", 573 | "name": "", 574 | "type": "uint32" 575 | }, 576 | { 577 | "internalType": "uint32", 578 | "name": "", 579 | "type": "uint32" 580 | } 581 | ], 582 | "stateMutability": "view", 583 | "type": "function" 584 | }, 585 | { 586 | "inputs": [ 587 | { 588 | "internalType": "bytes32", 589 | "name": "role", 590 | "type": "bytes32" 591 | } 592 | ], 593 | "name": "getRoleAdmin", 594 | "outputs": [ 595 | { 596 | "internalType": "bytes32", 597 | "name": "", 598 | "type": "bytes32" 599 | } 600 | ], 601 | "stateMutability": "view", 602 | "type": "function" 603 | }, 604 | { 605 | "inputs": [], 606 | "name": "getVaultToken", 607 | "outputs": [ 608 | { 609 | "internalType": "address", 610 | "name": "", 611 | "type": "address" 612 | } 613 | ], 614 | "stateMutability": "view", 615 | "type": "function" 616 | }, 617 | { 618 | "inputs": [ 619 | { 620 | "internalType": "bytes32", 621 | "name": "role", 622 | "type": "bytes32" 623 | }, 624 | { 625 | "internalType": "address", 626 | "name": "account", 627 | "type": "address" 628 | } 629 | ], 630 | "name": "grantRole", 631 | "outputs": [], 632 | "stateMutability": "nonpayable", 633 | "type": "function" 634 | }, 635 | { 636 | "inputs": [ 637 | { 638 | "internalType": "bytes32", 639 | "name": "role", 640 | "type": "bytes32" 641 | }, 642 | { 643 | "internalType": "address", 644 | "name": "account", 645 | "type": "address" 646 | } 647 | ], 648 | "name": "hasRole", 649 | "outputs": [ 650 | { 651 | "internalType": "bool", 652 | "name": "", 653 | "type": "bool" 654 | } 655 | ], 656 | "stateMutability": "view", 657 | "type": "function" 658 | }, 659 | { 660 | "inputs": [ 661 | { 662 | "internalType": "address", 663 | "name": "tokenAddr", 664 | "type": "address" 665 | } 666 | ], 667 | "name": "initialize", 668 | "outputs": [], 669 | "stateMutability": "nonpayable", 670 | "type": "function" 671 | }, 672 | { 673 | "inputs": [ 674 | { 675 | "internalType": "uint64", 676 | "name": "grantId", 677 | "type": "uint64" 678 | }, 679 | { 680 | "internalType": "uint64", 681 | "name": "lockTime", 682 | "type": "uint64" 683 | } 684 | ], 685 | "name": "lockGrant", 686 | "outputs": [], 687 | "stateMutability": "nonpayable", 688 | "type": "function" 689 | }, 690 | { 691 | "inputs": [ 692 | { 693 | "internalType": "uint64", 694 | "name": "grantId", 695 | "type": "uint64" 696 | } 697 | ], 698 | "name": "notifyGrantTransfer", 699 | "outputs": [], 700 | "stateMutability": "nonpayable", 701 | "type": "function" 702 | }, 703 | { 704 | "inputs": [], 705 | "name": "paused", 706 | "outputs": [ 707 | { 708 | "internalType": "bool", 709 | "name": "", 710 | "type": "bool" 711 | } 712 | ], 713 | "stateMutability": "view", 714 | "type": "function" 715 | }, 716 | { 717 | "inputs": [ 718 | { 719 | "internalType": "uint64", 720 | "name": "grantId", 721 | "type": "uint64" 722 | } 723 | ], 724 | "name": "removeGrant", 725 | "outputs": [], 726 | "stateMutability": "nonpayable", 727 | "type": "function" 728 | }, 729 | { 730 | "inputs": [ 731 | { 732 | "internalType": "uint64", 733 | "name": "grantId", 734 | "type": "uint64" 735 | }, 736 | { 737 | "internalType": "bytes32", 738 | "name": "name", 739 | "type": "bytes32" 740 | } 741 | ], 742 | "name": "renameGrant", 743 | "outputs": [], 744 | "stateMutability": "nonpayable", 745 | "type": "function" 746 | }, 747 | { 748 | "inputs": [ 749 | { 750 | "internalType": "bytes32", 751 | "name": "role", 752 | "type": "bytes32" 753 | }, 754 | { 755 | "internalType": "address", 756 | "name": "account", 757 | "type": "address" 758 | } 759 | ], 760 | "name": "renounceRole", 761 | "outputs": [], 762 | "stateMutability": "nonpayable", 763 | "type": "function" 764 | }, 765 | { 766 | "inputs": [ 767 | { 768 | "internalType": "address", 769 | "name": "addr", 770 | "type": "address" 771 | }, 772 | { 773 | "internalType": "uint256", 774 | "name": "amount", 775 | "type": "uint256" 776 | }, 777 | { 778 | "internalType": "bytes", 779 | "name": "data", 780 | "type": "bytes" 781 | } 782 | ], 783 | "name": "rescueCall", 784 | "outputs": [], 785 | "stateMutability": "nonpayable", 786 | "type": "function" 787 | }, 788 | { 789 | "inputs": [ 790 | { 791 | "internalType": "bytes32", 792 | "name": "role", 793 | "type": "bytes32" 794 | }, 795 | { 796 | "internalType": "address", 797 | "name": "account", 798 | "type": "address" 799 | } 800 | ], 801 | "name": "revokeRole", 802 | "outputs": [], 803 | "stateMutability": "nonpayable", 804 | "type": "function" 805 | }, 806 | { 807 | "inputs": [ 808 | { 809 | "internalType": "uint32", 810 | "name": "lockTime", 811 | "type": "uint32" 812 | } 813 | ], 814 | "name": "setClaimTransferLockTime", 815 | "outputs": [], 816 | "stateMutability": "nonpayable", 817 | "type": "function" 818 | }, 819 | { 820 | "inputs": [ 821 | { 822 | "internalType": "uint128", 823 | "name": "amount", 824 | "type": "uint128" 825 | }, 826 | { 827 | "internalType": "uint64", 828 | "name": "interval", 829 | "type": "uint64" 830 | }, 831 | { 832 | "internalType": "uint32", 833 | "name": "cooldown", 834 | "type": "uint32" 835 | }, 836 | { 837 | "internalType": "uint32", 838 | "name": "cooldownLock", 839 | "type": "uint32" 840 | } 841 | ], 842 | "name": "setManagerGrantLimits", 843 | "outputs": [], 844 | "stateMutability": "nonpayable", 845 | "type": "function" 846 | }, 847 | { 848 | "inputs": [ 849 | { 850 | "internalType": "bool", 851 | "name": "paused", 852 | "type": "bool" 853 | } 854 | ], 855 | "name": "setPaused", 856 | "outputs": [], 857 | "stateMutability": "nonpayable", 858 | "type": "function" 859 | }, 860 | { 861 | "inputs": [ 862 | { 863 | "internalType": "address", 864 | "name": "manager", 865 | "type": "address" 866 | } 867 | ], 868 | "name": "setProxyManager", 869 | "outputs": [], 870 | "stateMutability": "nonpayable", 871 | "type": "function" 872 | }, 873 | { 874 | "inputs": [ 875 | { 876 | "internalType": "bytes4", 877 | "name": "interfaceId", 878 | "type": "bytes4" 879 | } 880 | ], 881 | "name": "supportsInterface", 882 | "outputs": [ 883 | { 884 | "internalType": "bool", 885 | "name": "", 886 | "type": "bool" 887 | } 888 | ], 889 | "stateMutability": "view", 890 | "type": "function" 891 | }, 892 | { 893 | "inputs": [ 894 | { 895 | "internalType": "uint64", 896 | "name": "grantId", 897 | "type": "uint64" 898 | }, 899 | { 900 | "internalType": "address", 901 | "name": "addr", 902 | "type": "address" 903 | } 904 | ], 905 | "name": "transferGrant", 906 | "outputs": [], 907 | "stateMutability": "nonpayable", 908 | "type": "function" 909 | }, 910 | { 911 | "inputs": [ 912 | { 913 | "internalType": "uint64", 914 | "name": "grantId", 915 | "type": "uint64" 916 | }, 917 | { 918 | "internalType": "uint128", 919 | "name": "amount", 920 | "type": "uint128" 921 | }, 922 | { 923 | "internalType": "uint64", 924 | "name": "interval", 925 | "type": "uint64" 926 | } 927 | ], 928 | "name": "updateGrant", 929 | "outputs": [], 930 | "stateMutability": "nonpayable", 931 | "type": "function" 932 | }, 933 | { 934 | "stateMutability": "payable", 935 | "type": "receive" 936 | } 937 | ] -------------------------------------------------------------------------------- /fundingvault/test/TestFundingVaultProxy.js: -------------------------------------------------------------------------------- 1 | const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers"); 2 | const { time } = require("@nomicfoundation/hardhat-network-helpers"); 3 | const { expect } = require("chai"); 4 | // Check if HARDHAT_DEBUG is set. If set, debug prints will be enabled. 5 | // Usage: HARDHAT_DEBUG=1 npx hardhat test to enable debug prints, 6 | // omit the env var to disable debug prints. 7 | const debug = process.env.HARDHAT_DEBUG !== undefined; 8 | 9 | function toHex(str, len) { 10 | var result = ''; 11 | var ccode; 12 | for (var i=0; i<str.length; i++) { 13 | ccode = str.charCodeAt(i); 14 | if(ccode) { 15 | result += ccode.toString(16); 16 | } 17 | } 18 | while(result.length < len * 2) { 19 | result += "0"; 20 | } 21 | 22 | return "0x"+result; 23 | } 24 | 25 | describe("Funding Vault Tests", function () { 26 | // We define a fixture to reuse the same setup in every test. 27 | // We use loadFixture to run this setup once, snapshot that state, 28 | // and reset Hardhat Network to that snapshot in every test. 29 | async function deployProxyFixture() { 30 | const [owner] = await ethers.getSigners(); 31 | // First deploy proxy 32 | let Proxy = await ethers.getContractFactory("FundingVaultProxy"); 33 | let proxy = await Proxy.connect(owner).deploy(); 34 | let proxyAddress = await proxy.getAddress(); 35 | 36 | // Then deploy token 37 | let FundingVaultToken = await ethers.getContractFactory("FundingVaultToken"); 38 | let token = await FundingVaultToken.deploy(proxyAddress); 39 | let tokenAddress = await token.getAddress(); 40 | 41 | // Lastly, deploy vault implementation 42 | let FundingVault = await ethers.getContractFactory("FundingVaultV1"); 43 | let vault = await FundingVault.connect(owner).deploy(); 44 | let vaultAddress = await vault.getAddress(); 45 | 46 | // Create instance of vault implementation, attached to the proxy contract 47 | let proxiedVault = FundingVault.attach(proxyAddress); 48 | 49 | // Initialize vault thorugh proxy's upgradeToAndCall 50 | const initData = vault.interface.encodeFunctionData("initialize(address)", [tokenAddress]); 51 | await proxy.connect(owner).upgradeToAndCall(vaultAddress, initData); 52 | return {proxy, token, vault, proxiedVault, owner}; 53 | } 54 | 55 | describe("Deployment", function () { 56 | it("Token vault should be proxy address", async function () { 57 | const {proxy, token, vault, proxiedVault, owner} = await loadFixture(deployProxyFixture); 58 | expect(await token.getVault()).to.equal(await proxy.getAddress()); 59 | }); 60 | it("Proxy implementation should be vault impl address", async function () { 61 | const {proxy, token, vault, proxiedVault, owner} = await loadFixture(deployProxyFixture); 62 | expect(await proxy.implementation()).to.equal(await vault.getAddress()); 63 | }); 64 | it("Vault implementation's token should be token address", async function () { 65 | const {proxy, token, vault, proxiedVault, owner} = await loadFixture(deployProxyFixture); 66 | expect(await proxiedVault.getVaultToken()).to.equal(await token.getAddress()); 67 | }); 68 | }); 69 | 70 | describe("Grant Management (as owner)", function () { 71 | async function prepareTest(proxiedVault) { 72 | const [owner, grantee] = await ethers.getSigners(); 73 | const ownerAddress = await owner.getAddress(); 74 | 75 | // add manager role to owner 76 | let managerRole = await proxiedVault.GRANT_MANAGER_ROLE(); 77 | await proxiedVault.grantRole(managerRole, ownerAddress); 78 | 79 | // diable grant locking after creation / transfer for easier test 80 | // otherwise, we'd have to wait 10mins after grant creation to see some claimable balance 81 | await proxiedVault.setClaimTransferLockTime(0); 82 | 83 | // send some funds to the vault, otherwise there is nothing to claim 84 | await owner.sendTransaction({ 85 | to: await proxiedVault.getAddress(), 86 | value: 2000000000000000000000n, // send 2000 ETH 87 | }); 88 | } 89 | 90 | it("Create Grant", async function () { 91 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 92 | const [owner, grantee] = await ethers.getSigners(); 93 | await prepareTest(proxiedVault); 94 | 95 | // create grant (1000 ETH per hour) 96 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 97 | 98 | // check if grant token has been sent to grantee 99 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 100 | 101 | // check claimable balance as grantee 102 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(1000000000000000000000n); // 1000 ETH should be claimable 103 | }); 104 | it("Update Grant amount (max unclaimed balance)", async function () { 105 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 106 | const [owner, grantee] = await ethers.getSigners(); 107 | await prepareTest(proxiedVault); 108 | 109 | // create grant (1000 ETH per hour) 110 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 111 | 112 | // check if grant token has been sent to grantee 113 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 114 | 115 | // update grant (500 ETH per hour) 116 | await proxiedVault.updateGrant(1, 500, 3600); 117 | 118 | // check if grantee still holds the grant token 119 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 120 | 121 | // check claimable balance as grantee 122 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(500000000000000000000n); // 500 ETH should be claimable 123 | }); 124 | it("Update Grant amount (no unclaimed balance)", async function () { 125 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 126 | const [owner, grantee] = await ethers.getSigners(); 127 | await prepareTest(proxiedVault); 128 | 129 | // create grant (1000 ETH per hour) 130 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 131 | 132 | // check if grant token has been sent to grantee 133 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 134 | 135 | // claim all available balance 136 | await proxiedVault.connect(grantee).claim(0); 137 | 138 | // update grant (500 ETH per hour) 139 | await proxiedVault.updateGrant(1, 500, 3600); 140 | 141 | // check claimable balance as grantee 142 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0n); // 0 ETH should be claimable 143 | }); 144 | it("Update Grant amount (half unclaimed balance)", async function () { 145 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 146 | const [owner, grantee] = await ethers.getSigners(); 147 | await prepareTest(proxiedVault); 148 | 149 | // create grant (1000 ETH per hour) 150 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 151 | 152 | // check if grant token has been sent to grantee 153 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 154 | 155 | // claim all available balance 156 | await proxiedVault.connect(grantee).claim(0); 157 | 158 | // "wait" 30mins 159 | await time.increase(1800); 160 | 161 | // check claimable balance as grantee 162 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(500000000000000000000n); // 500 ETH should be claimable 163 | 164 | // update grant (500 ETH per hour) 165 | await proxiedVault.updateGrant(1, 500, 3600); 166 | 167 | // check claimable balance as grantee 168 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(250000000000000000000n); // 250 ETH should be claimable 169 | }); 170 | it("Update Grant interval (max unclaimed balance)", async function () { 171 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 172 | const [owner, grantee] = await ethers.getSigners(); 173 | await prepareTest(proxiedVault); 174 | 175 | // create grant (1000 ETH per hour) 176 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 177 | 178 | // check if grant token has been sent to grantee 179 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 180 | 181 | // update grant (1000 ETH per 2 hours) 182 | await proxiedVault.updateGrant(1, 1000, 7200); 183 | 184 | // check claimable balance as grantee 185 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(500000000000000000000n); // 500 ETH should be claimable 186 | }); 187 | it("Update Grant interval (no unclaimed balance)", async function () { 188 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 189 | const [owner, grantee] = await ethers.getSigners(); 190 | await prepareTest(proxiedVault); 191 | 192 | // create grant (1000 ETH per hour) 193 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 194 | 195 | // check if grant token has been sent to grantee 196 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 197 | 198 | // claim all available balance 199 | await proxiedVault.connect(grantee).claim(0); 200 | 201 | // update grant (1000 ETH per 2 hours) 202 | await proxiedVault.updateGrant(1, 1000, 7200); 203 | 204 | // check claimable balance as grantee 205 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0n); // 0 ETH should be claimable 206 | }); 207 | it("Update Grant interval (half unclaimed balance)", async function () { 208 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 209 | const [owner, grantee] = await ethers.getSigners(); 210 | await prepareTest(proxiedVault); 211 | 212 | // create grant (1000 ETH per hour) 213 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 214 | 215 | // check if grant token has been sent to grantee 216 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 217 | 218 | // claim all available balance 219 | await proxiedVault.connect(grantee).claim(0); 220 | 221 | // "wait" 30mins 222 | await time.increase(1800); 223 | 224 | // check claimable balance as grantee 225 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(500000000000000000000n); // 500 ETH should be claimable 226 | 227 | // update grant (1000 ETH per 2 hour) 228 | await proxiedVault.updateGrant(1, 1000, 7200); 229 | 230 | // check claimable balance as grantee 231 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(250000000000000000000n); // 250 ETH should be claimable 232 | }); 233 | it("Delete Grant", async function () { 234 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 235 | const [owner, grantee] = await ethers.getSigners(); 236 | await prepareTest(proxiedVault); 237 | 238 | // create grant (1000 ETH per hour) 239 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 240 | 241 | // check if grant token has been sent to grantee 242 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 243 | 244 | // remove grant 245 | await proxiedVault.removeGrant(1); 246 | 247 | // check if grant token has been burned 248 | expect(await token.balanceOf(grantee.getAddress())).to.equal(0); 249 | 250 | // check claimable balance as grantee 251 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0n); // 0 ETH should be claimable 252 | }); 253 | it("Transfer Grant", async function () { 254 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 255 | const [owner, grantee, grantee2] = await ethers.getSigners(); 256 | await prepareTest(proxiedVault); 257 | 258 | // create grant (1000 ETH per hour) 259 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 260 | 261 | // check if grant token has been sent to grantee 262 | expect(await token.balanceOf(grantee.getAddress())).to.equal(1); 263 | 264 | // transfer grant 265 | await proxiedVault.transferGrant(1, grantee2.getAddress()); 266 | 267 | // check if grant token has been moved 268 | expect(await token.balanceOf(grantee.getAddress())).to.equal(0); 269 | expect(await token.balanceOf(grantee2.getAddress())).to.equal(1); 270 | 271 | // check claimable balance 272 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0n); // 0 ETH should be claimable 273 | expect(await proxiedVault.connect(grantee2).getClaimableBalance()).to.equal(1000000000000000000000n); // 1000 ETH should be claimable 274 | }); 275 | }); 276 | describe("Manager Limits", function () { 277 | async function prepareTest(proxiedVault) { 278 | const [owner, manager, grantee] = await ethers.getSigners(); 279 | 280 | // add manager role to owner 281 | let managerRole = await proxiedVault.GRANT_MANAGER_ROLE(); 282 | await proxiedVault.grantRole(managerRole, owner.getAddress()); 283 | 284 | // add manager role to manager 285 | await proxiedVault.grantRole(managerRole, manager.getAddress()); 286 | 287 | // diable grant locking after creation / transfer for easier test 288 | // otherwise, we'd have to wait 10mins after grant creation to see some claimable balance 289 | await proxiedVault.setClaimTransferLockTime(0); 290 | 291 | // configure manager limits for tests 292 | await proxiedVault.setManagerGrantLimits( 293 | 1000, // amount 294 | 3600, // interval 295 | 3600, // cooldown (number of seconds added to the cooldown clock when a grant worth amount/interval got managed) 296 | 1800, // cooldownLock (lock manager when cooldown clock is above this value) 297 | ); 298 | /* The manager limits above have the following effect: 299 | * - managers are limited to create/update/transfer grants with a maximum allowance of amount / interval ETH. 300 | * with the values above, manages are limited to manage grants worth 1000ETH/3600sec = 0.277 ETH/sec 301 | * - managers have their own cooldown system to avoid mass creation of grants from hijacked wallets. 302 | * when a manager creates/updates/transfers a grant with the max allowance (amount / interval), `cooldown` seconds are added to the managers cooldown clock. 303 | * if the cooldown clock exceeds the `cooldownLock` value, the manager cannot create/update/transfer grants anymore and needs to wait for the clock value to be lower than `cooldownLock` again. 304 | * with the values above, managers can create one grant with 1000ETH/3600sec, which adds 3600secs to their cooldown clock. 305 | * they are then locked for 1800secs, before they can create another grant. 306 | */ 307 | 308 | // send some funds to the vault, otherwise there is nothing to claim 309 | await owner.sendTransaction({ 310 | to: await proxiedVault.getAddress(), 311 | value: 2000000000000000000000n, // send 2000 ETH 312 | }); 313 | } 314 | 315 | it("Grant creation limits", async function () { 316 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 317 | const [owner, manager, grantee] = await ethers.getSigners(); 318 | await prepareTest(proxiedVault); 319 | 320 | // create grant (2000 ETH per hour), should fail 321 | var txErr = null; 322 | try { 323 | await proxiedVault.connect(manager).createGrant(grantee.getAddress(), 2000, 3600, toHex("Test Grant", 32)); 324 | } catch(ex) { 325 | txErr = ex; 326 | } 327 | expect(txErr?.toString()).to.match(/amount exceeds manager limits/); 328 | 329 | // create 2 grants (500 ETH per hour) 330 | await proxiedVault.connect(manager).createGrant(grantee.getAddress(), 250, 3600, toHex("Test Grant 1", 32)); 331 | await proxiedVault.connect(manager).createGrant(grantee.getAddress(), 750, 3600, toHex("Test Grant 2", 32)); 332 | 333 | // check manager cooldown 334 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(3601); 335 | 336 | // create grant (500 ETH per hour), should fail 337 | var txErr = null; 338 | try { 339 | await proxiedVault.connect(manager).createGrant(grantee.getAddress(), 500, 3600, toHex("Test Grant 3", 32)); 340 | } catch(ex) { 341 | txErr = ex; 342 | } 343 | expect(txErr?.toString()).to.match(/manager cooldown/); 344 | 345 | // "wait" 30mins 346 | await time.increase(1802); 347 | 348 | // create grant (1000 ETH per hour) 349 | await proxiedVault.connect(manager).createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant 4", 32)); 350 | 351 | // check manager cooldown 352 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(5400); 353 | }); 354 | it("Grant update limits", async function () { 355 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 356 | const [owner, manager, grantee] = await ethers.getSigners(); 357 | await prepareTest(proxiedVault); 358 | 359 | // create grant (1000 ETH per hour) 360 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 361 | 362 | // update grant (500 ETH per hour) 363 | // this is a decrease of the grant allowance, so it shouldn't affect the manager cooldown 364 | await proxiedVault.connect(manager).updateGrant(1, 500, 3600); 365 | 366 | // check manager cooldown 367 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(0); 368 | 369 | // update grant (1000 ETH per hour) 370 | // this is a increase of the grant allowance by 500 ETH/3600sec 371 | await proxiedVault.connect(manager).updateGrant(1, 1000, 3600); 372 | 373 | // check manager cooldown 374 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(1801); 375 | }); 376 | it("Grant transfer limits", async function () { 377 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 378 | const [owner, manager, grantee, grantee2] = await ethers.getSigners(); 379 | await prepareTest(proxiedVault); 380 | 381 | // create test grants 382 | await proxiedVault.createGrant(grantee.getAddress(), 100, 3600, toHex("Test Grant 1", 32)); 383 | await proxiedVault.createGrant(grantee.getAddress(), 100, 3600, toHex("Test Grant 2", 32)); 384 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant 3", 32)); 385 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant 4", 32)); 386 | 387 | // transfer grant (100 ETH per hour) 388 | await proxiedVault.connect(manager).transferGrant(1, grantee2.getAddress()); 389 | 390 | // check manager cooldown 391 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(360); 392 | 393 | // transfer grant (100 ETH per hour) 394 | await proxiedVault.connect(manager).transferGrant(2, grantee2.getAddress()); 395 | 396 | // check manager cooldown 397 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(720); 398 | 399 | // transfer grant (1000 ETH per hour) 400 | await proxiedVault.connect(manager).transferGrant(3, grantee2.getAddress()); 401 | 402 | // check manager cooldown 403 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(4321); 404 | 405 | // transfer grant (1000 ETH per hour), should fail 406 | var txErr = null; 407 | try { 408 | await proxiedVault.connect(manager).transferGrant(4, grantee2.getAddress()); 409 | } catch(ex) { 410 | txErr = ex; 411 | } 412 | expect(txErr?.toString()).to.match(/manager cooldown/); 413 | 414 | // "wait" 2522 sec (4321 - 1800) + 1 415 | await time.increase(2522); 416 | 417 | // transfer grant (1000 ETH per hour) 418 | await proxiedVault.connect(manager).transferGrant(4, grantee2.getAddress()); 419 | 420 | // check manager cooldown 421 | expect(await proxiedVault.getManagerCooldown(manager.getAddress())).to.equal(5400); 422 | }); 423 | 424 | }); 425 | 426 | describe("Request funds", function () { 427 | async function prepareTest(proxiedVault) { 428 | const [owner, grantee] = await ethers.getSigners(); 429 | const ownerAddress = await owner.getAddress(); 430 | 431 | // add manager role to owner 432 | let managerRole = await proxiedVault.GRANT_MANAGER_ROLE(); 433 | await proxiedVault.grantRole(managerRole, ownerAddress); 434 | 435 | // diable grant locking after creation / transfer for easier test 436 | // otherwise, we'd have to wait 10mins after grant creation to see some claimable balance 437 | await proxiedVault.setClaimTransferLockTime(0); 438 | 439 | // send some funds to the vault, otherwise there is nothing to claim 440 | await owner.sendTransaction({ 441 | to: await proxiedVault.getAddress(), 442 | value: 2000000000000000000000n, // send 2000 ETH 443 | }); 444 | } 445 | 446 | it("Request max available balance", async function () { 447 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 448 | const [owner, grantee] = await ethers.getSigners(); 449 | await prepareTest(proxiedVault); 450 | 451 | // create grant (1000 ETH per hour) 452 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 453 | 454 | // claim all available balance 455 | let oldGranteeBalance = await ethers.provider.getBalance(grantee.getAddress()); 456 | let claimTx = await proxiedVault.connect(grantee).claim(0); 457 | 458 | // await receipt 459 | let claimTxReceipt = await claimTx.wait(); 460 | let claimTxFees = claimTxReceipt.gasUsed * claimTxReceipt.gasPrice; 461 | 462 | // check balance increase 463 | let newGranteeBalance = await ethers.provider.getBalance(grantee.getAddress()); 464 | expect(newGranteeBalance - oldGranteeBalance).to.equal(1000000000000000000000n - claimTxFees); // 1000 ETH increase 465 | 466 | // check claimable balance as grantee 467 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0); // 0 ETH should be claimable 468 | }); 469 | it("Request funds in multiple small claims", async function () { 470 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 471 | const [owner, grantee] = await ethers.getSigners(); 472 | await prepareTest(proxiedVault); 473 | 474 | // create grant (1000 ETH per hour) 475 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 476 | 477 | for(let i = 0; i < 10; i++) { 478 | // claim 100 ETH (1/10 of granted amount) 479 | let oldGranteeBalance = await ethers.provider.getBalance(grantee.getAddress()); 480 | let claimTx = await proxiedVault.connect(grantee).claim(100000000000000000000n); 481 | 482 | // await receipt 483 | let claimTxReceipt = await claimTx.wait(); 484 | let claimTxFees = claimTxReceipt.gasUsed * claimTxReceipt.gasPrice; 485 | 486 | // check balance increase 487 | let newGranteeBalance = await ethers.provider.getBalance(grantee.getAddress()); 488 | expect(newGranteeBalance - oldGranteeBalance).to.equal(100000000000000000000n - claimTxFees); // 100 ETH increase 489 | } 490 | 491 | // check claimable balance as grantee 492 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0); // 0 ETH should be claimable 493 | }); 494 | it("Request funds after half interval", async function () { 495 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 496 | const [owner, grantee] = await ethers.getSigners(); 497 | await prepareTest(proxiedVault); 498 | 499 | // create grant (1000 ETH per hour) 500 | await proxiedVault.createGrant(grantee.getAddress(), 1000, 3600, toHex("Test Grant", 32)); 501 | 502 | // claim all available balance 503 | await proxiedVault.connect(grantee).claim(0); 504 | 505 | // "wait" 30mins 506 | await time.increase(1800); 507 | 508 | // claim all available balance again 509 | let oldGranteeBalance = await ethers.provider.getBalance(grantee.getAddress()); 510 | let claimTx = await proxiedVault.connect(grantee).claim(0); 511 | 512 | // await receipt 513 | let claimTxReceipt = await claimTx.wait(); 514 | let claimTxFees = claimTxReceipt.gasUsed * claimTxReceipt.gasPrice; 515 | 516 | // check balance increase 517 | let newGranteeBalance = await ethers.provider.getBalance(grantee.getAddress()); 518 | expect(newGranteeBalance - oldGranteeBalance).to.equal(500000000000000000000n - claimTxFees); // 500 ETH increase 519 | 520 | // check claimable balance as grantee 521 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(0); // 0 ETH should be claimable 522 | }); 523 | }); 524 | 525 | describe('Fuzz Testing', function () { 526 | it('Create Grant fuzzing', async function () { 527 | const {proxy, token, vault, proxiedVault} = await loadFixture(deployProxyFixture); 528 | const [owner] = await ethers.getSigners(); 529 | const ownerAddress = await owner.getAddress(); 530 | 531 | // add manager role to owner 532 | let managerRole = await proxiedVault.GRANT_MANAGER_ROLE(); 533 | await proxiedVault.grantRole(managerRole, ownerAddress); 534 | 535 | // diable grant locking after creation / transfer for easier test 536 | // otherwise, we'd have to wait 10mins after grant creation to see some claimable balance 537 | await proxiedVault.setClaimTransferLockTime(0); 538 | 539 | for (let i = 0; i < 15; i++) { 540 | // Get a different signer for each iteration 541 | // Start from the third signer to avoid using the owner account 542 | // Note that if the number of fuzz iterations exceed 10, there may 543 | // be an out-of-bounds on the array returned by the ethers.getSigners() 544 | // method. By default, Hardhat Network creates 20 accounts for testing. 545 | const grantee = (await ethers.getSigners())[i + 1]; 546 | const granteeAddress = await grantee.getAddress(); 547 | 548 | // Generate random values for grant and claim 549 | const randomGrant = Math.floor(Math.random() * 1000); 550 | const randomTime = Math.floor(Math.random() * 3600); 551 | 552 | if (debug) { 553 | console.log(`Test ${i + 1}: Grantee = ${granteeAddress}, Grant = ${randomGrant}, Time = ${randomTime}`); 554 | } 555 | 556 | // Create grant with random values 557 | await proxiedVault.createGrant(granteeAddress, randomGrant, randomTime, toHex("Test Grant " + (i + 1), 32)); 558 | if (debug) { 559 | console.log('Grant created'); 560 | } 561 | 562 | // check if grant token has been sent to grantee 563 | expect(await token.balanceOf(granteeAddress)).to.equal(1); 564 | if (debug) { 565 | console.log('Token balance check passed'); 566 | } 567 | 568 | // check claimable balance as grantee 569 | expect(await proxiedVault.connect(grantee).getClaimableBalance()).to.equal(ethers.parseEther(randomGrant.toString())); 570 | if (debug) { 571 | console.log('Claimable balance check passed') 572 | } 573 | } 574 | }); 575 | }); 576 | }); 577 | --------------------------------------------------------------------------------