├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .solhint.json ├── .solhintignore ├── README.md ├── audits └── PeckShield-Audit-Report-LlamaPay-v1.0.pdf ├── brownie-config.yaml ├── contracts ├── LlamaPay.sol ├── LlamaPayFactory.sol ├── fork │ └── BoringBatchable.sol └── mock │ └── MockToken.sol ├── deploy └── 001_factory.ts ├── hardhat.config.ts ├── package-lock.json ├── package.json ├── scripts └── deploy.ts ├── test ├── factory.ts ├── llamaPay.ts └── utils.ts ├── tsconfig.json └── v2 ├── Adapter.sol ├── LlamaPay.sol ├── LlamaPayFactory.sol └── test.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es2021: true, 5 | mocha: true, 6 | node: true, 7 | }, 8 | plugins: ["@typescript-eslint"], 9 | extends: [ 10 | "standard", 11 | "plugin:prettier/recommended", 12 | "plugin:node/recommended", 13 | ], 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | ecmaVersion: 12, 17 | }, 18 | rules: { 19 | "node/no-unsupported-features/es-syntax": [ 20 | "error", 21 | { ignores: ["modules"] }, 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | bin 7 | 8 | deployments 9 | 10 | #Hardhat files 11 | cache 12 | artifacts 13 | 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | downloads/ 28 | eggs/ 29 | .eggs/ 30 | lib/ 31 | lib64/ 32 | parts/ 33 | sdist/ 34 | var/ 35 | wheels/ 36 | pip-wheel-metadata/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .nox/ 57 | .coverage 58 | .coverage.* 59 | .cache 60 | nosetests.xml 61 | coverage.xml 62 | *.cover 63 | *.py,cover 64 | .hypothesis/ 65 | .pytest_cache/ 66 | 67 | # Translations 68 | *.mo 69 | *.pot 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | db.sqlite3 75 | db.sqlite3-journal 76 | 77 | # Flask stuff: 78 | instance/ 79 | .webassets-cache 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # PyBuilder 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # eth-brownie 145 | .history 146 | reports/ 147 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | hardhat.config.ts 2 | scripts 3 | test 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage* 5 | gasReporterOutput.json 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "compiler-version": ["error", "^0.8.0"], 5 | "func-visibility": ["warn", { "ignoreConstructors": true }] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.solhintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LlamaPay 2 | 3 | Automate salary txs, streaming them by the second so employees can withdraw whenever they want and you don't have to deal with sending txs manually. 4 | 5 | Features: 6 | - Easy top up for all streams in 1 operation 7 | - Low gas 8 | - Ability to trigger withdraws for someone else (for people that don't use metamask) 9 | - Open source & verified contracts 10 | - Fast UI 11 | - Available on all chains 12 | - No big precision errors 13 | - Works with all tokens 14 | - Deposits and withdrawals in 1 operation 15 | - Works with debt, if you forget to pay on time we keep track of everything you missed 16 | - No need to deposit all money at the start of the stream 17 | 18 | ## Why? 19 | I used to handle payments by just sending transactions at the end of the month, however that soon turned into a pain and I started looking at alternatives that could automate it. Then I started using superfluid for that, and while the concept was great, there were many small execution problems that made using it very uncomfortable. Llamapay is my attempt at scratching my own itch, to build a system that exactly fits our needs at defillama, and, as I'm sure there's other teams that could benefit from it too, we plan to open source it and release it for everyone to use. 20 | 21 | ## A note for integrations: decimals 22 | LlamaPay uses an internal representation with 20 decimals for all it's numbers. The reason for this is to prevent math precision errors which can end up being significant (eg: if we used native decimals, a 1k/mo USDC stream becomes 994/mo instead). 23 | 24 | This has the issue that all integrations need to be mindful of that, so here are a list of methods and whether they use native token decimals or not: 25 | | Method | Decimals| 26 | |--------|---------| 27 | |getPayerBalance|native| 28 | |withdrawable|native| 29 | |deposit|native| 30 | |balances|1e20| 31 | |amountPerSec everywhere| 1e20| 32 | |createStream|1e20| 33 | |modifyStream|1e20| 34 | |withdrawPayer|1e20| 35 | 36 | > LlamaPay doesn't work with rebasing tokens or tokens that have fee-on-transfer 37 | 38 | ## Features 39 | 40 | ### Gas costs 41 | Cost to create a stream: 42 | | Protocol | Cost (gas) | 43 | |----------|-------------| 44 | | LlamaPay | 69,963 45 | | Sablier | 240,070 46 | | SuperFluid | 279,992 47 | 48 | So LlamaPay is 3.2x-3.7x cheaper than the competition! 49 | 50 | ### No requirement on depositing all money needed for the stream 51 | Sablier requires you to pick a duration for each stream and deposit all the money needed for the entirety of the stream at the start. This doesn't map well to salaries, since length is indeterminate. 52 | 53 | This system forces you to keep creating new streams as the old ones die and you have to provide a large amount of capital that gets locked if you pick long durations. Instead a much better system is one where you create streams of indefinite duration and these just siphon money out of a pool, which makes it possible to top all streams up in a single operation and just provide money as it's needed to maintain them. 54 | 55 | ### Withdrawals that anyone can trigger 56 | Some people will choose to provide an address that belongs to a CEX or a wallet that can't make ethereum calls. With current solutions this makes it impossible for them to claim their money, but llamapay allows anyone to trigger withdrawals, so it works in these cases too. 57 | 58 | They can just set a CEX address and have someone else trigger withdrawals or trigger them themselves using another wallet. This greatly simplifies operations and possible problems. 59 | 60 | ### Available on all chains 61 | After our public release, llamapay will be available on all EVM chains and all the contracts will share the same address across chains. 62 | 63 | ### No big precision errors 64 | Sablier uses the same units as the underlying token when handling math for the stream. This means that for tokens that have a low number for decimals(), such as USDC, this causes precision errors. For example: if you stream 1000 USDC to an address, you'll instead end up streaming 997 USDC instead due to these errors. 65 | 66 | LlamaPay operates internally with 20 decimals, which keep precision errors to a minimum. 67 | 68 | ### Works with all tokens 69 | Using any token is very easy, which is not the case for superfluid. 70 | 71 | ### Debt 72 | In superfluid, if you forget to top up your balance and the streams deplete all your balance, a bot will send a tx that will cancel all your streams, and takes part of your money, which you just lose. To get it working again you need to: 73 | - Create all the streams from scratch again 74 | - Calculate how much money payees have lost while the streams were down and send it to them manually 75 | - Just accept the losses from the cancellations 76 | 77 | This is not ideal because the whole reason you want this is to automate payments and the product should reduce your workload, not increase it like that. 78 | 79 | With LlamaPay, when your balance gets depleted, all that happens is that the payer just starts incurring debt, and when there's a new deposit that debt is paid and streams keep working as usual. If the payer really meant to stop streams by just not depositing more, they can just not deposit any more (users will be able to withdraw the money they received up until the payer's balance was depleted), or cancel individual streams, which will remove their debt. 80 | 81 | Payer never has the option to remove money that has already been streamed, once it has been streamed it can only be withdrawn to the payee's wallet. This makes it equivalent to superfluid's system from the POV of the payee, the only difference is that LlamaPay gives the option to the payer to just resume streams and repay debt easily, greatly simplifying the process in case they forgot or couldn't top up in time. 82 | 83 | Superfluid bot: https://polygonscan.com/address/0x759999a81fade877fe91ed4c09db45ee50db2044 84 | 85 | 86 | ### Single-tx operations 87 | Superfluid requires multiple operations for actions that are common (eg: withdraw money from a stream). LlamaPay simplifies these as maximum as possible and makes them available in a single tx. 88 | 89 | ## Roadmap 90 | 1. After UI is ready we'll deploy on mainnet and migrate all defillama payroll to it 91 | 2. We'll use it ourselves and modify anything we don't like 92 | 3. Remove rug code and release it publicly 93 | 4. Build v2 94 | 95 | ## V2 96 | - Earn yield while money is being streamed (I built a version with this under v2, but it's very complex so we aren't deploying it) 97 | - DCA with salary 98 | - Positions as NFTs to enable payees to use that on defi (eg: pawn it to get payment advances) 99 | 100 | Moonshots: 101 | - Privacy though zero knowledge proofs 102 | 103 | ---- 104 | 105 | ## Development 106 | 107 | ```shell 108 | npm test 109 | npx hardhat coverage 110 | npx hardhat deploy --network rinkeby 111 | npx hardhat etherscan-verify --network rinkeby 112 | npx hardhat verify --network rinkeby DEPLOYED_CONTRACT_ADDRESS 113 | ``` 114 | -------------------------------------------------------------------------------- /audits/PeckShield-Audit-Report-LlamaPay-v1.0.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LlamaPay/llamapay/90d18e11b94b02208100b3ac8756955b1b726d37/audits/PeckShield-Audit-Report-LlamaPay-v1.0.pdf -------------------------------------------------------------------------------- /brownie-config.yaml: -------------------------------------------------------------------------------- 1 | compiler: 2 | solc: 3 | remappings: 4 | - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@4.5.0" 5 | 6 | dependencies: 7 | - OpenZeppelin/openzeppelin-contracts@4.5.0 8 | -------------------------------------------------------------------------------- /contracts/LlamaPay.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: None 2 | pragma solidity ^0.8.0; 3 | 4 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 6 | import {BoringBatchable} from "./fork/BoringBatchable.sol"; 7 | 8 | interface Factory { 9 | function parameter() external view returns (address); 10 | } 11 | 12 | interface IERC20WithDecimals { 13 | function decimals() external view returns (uint8); 14 | } 15 | 16 | // All amountPerSec and all internal numbers use 20 decimals, these are converted to the right decimal on withdrawal/deposit 17 | // The reason for that is to minimize precision errors caused by integer math on tokens with low decimals (eg: USDC) 18 | 19 | // Invariant through the whole contract: lastPayerUpdate[anyone] <= block.timestamp 20 | // Reason: timestamps can't go back in time (https://github.com/ethereum/go-ethereum/blob/master/consensus/ethash/consensus.go#L274 and block timestamp definition on ethereum's yellow paper) 21 | // and we always set lastPayerUpdate[anyone] either to the current block.timestamp or a value lower than it 22 | // We could use this to optimize subtractions and avoid an unneded safemath check there for some gas savings 23 | // However this is obscure enough that we are not sure if a future ethereum network upgrade might remove this assertion 24 | // or if an ethereum fork might remove that code and invalidate the condition, causing our deployment on that chain to be vulnerable 25 | // This is dangerous because if someone can make a timestamp go back into the past they could steal all the money 26 | // So we forgo these optimizations and instead enforce this condition. 27 | 28 | // Another assumption is that all timestamps can fit in uint40, this will be true until year 231,800, so it's a safe assumption 29 | 30 | contract LlamaPay is BoringBatchable { 31 | using SafeERC20 for IERC20; 32 | 33 | struct Payer { 34 | uint40 lastPayerUpdate; 35 | uint216 totalPaidPerSec; // uint216 is enough to hold 1M streams of 3e51 tokens/yr, which is enough 36 | } 37 | 38 | mapping (bytes32 => uint) public streamToStart; 39 | mapping (address => Payer) public payers; 40 | mapping (address => uint) public balances; // could be packed together with lastPayerUpdate but gains are not high 41 | IERC20 public token; 42 | uint public DECIMALS_DIVISOR; 43 | 44 | event StreamCreated(address indexed from, address indexed to, uint216 amountPerSec, bytes32 streamId); 45 | event StreamCreatedWithReason(address indexed from, address indexed to, uint216 amountPerSec, bytes32 streamId, string reason); 46 | event StreamCancelled(address indexed from, address indexed to, uint216 amountPerSec, bytes32 streamId); 47 | event StreamPaused(address indexed from, address indexed to, uint216 amountPerSec, bytes32 streamId); 48 | event StreamModified(address indexed from, address indexed oldTo, uint216 oldAmountPerSec, bytes32 oldStreamId, address indexed to, uint216 amountPerSec, bytes32 newStreamId); 49 | event Withdraw(address indexed from, address indexed to, uint216 amountPerSec, bytes32 streamId, uint amount); 50 | event PayerDeposit(address indexed from, uint amount); 51 | event PayerWithdraw(address indexed from, uint amount); 52 | 53 | constructor(){ 54 | token = IERC20(Factory(msg.sender).parameter()); 55 | uint8 tokenDecimals = IERC20WithDecimals(address(token)).decimals(); 56 | DECIMALS_DIVISOR = 10**(20 - tokenDecimals); 57 | } 58 | 59 | function getStreamId(address from, address to, uint216 amountPerSec) public pure returns (bytes32){ 60 | return keccak256(abi.encodePacked(from, to, amountPerSec)); 61 | } 62 | 63 | function _createStream(address to, uint216 amountPerSec) internal returns (bytes32 streamId){ 64 | streamId = getStreamId(msg.sender, to, amountPerSec); 65 | require(amountPerSec > 0, "amountPerSec can't be 0"); 66 | require(streamToStart[streamId] == 0, "stream already exists"); 67 | streamToStart[streamId] = block.timestamp; 68 | 69 | Payer storage payer = payers[msg.sender]; 70 | uint totalPaid; 71 | uint delta = block.timestamp - payer.lastPayerUpdate; 72 | unchecked { 73 | totalPaid = delta * uint(payer.totalPaidPerSec); 74 | } 75 | balances[msg.sender] -= totalPaid; // implicit check that balance >= totalPaid, can't create a new stream unless there's no debt 76 | 77 | payer.lastPayerUpdate = uint40(block.timestamp); 78 | payer.totalPaidPerSec += amountPerSec; 79 | 80 | // checking that no overflow will ever happen on totalPaidPerSec is important because if there's an overflow later: 81 | // - if we don't have overflow checks -> it would be possible to steal money from other people 82 | // - if there are overflow checks -> money will be stuck forever as all txs (from payees of the same payer) will revert 83 | // which can be used to rug employees and make them unable to withdraw their earnings 84 | // Thus it's extremely important that no user is allowed to enter any value that later on could trigger an overflow. 85 | // We implicitly prevent this here because amountPerSec/totalPaidPerSec is uint216 and is only ever multiplied by timestamps 86 | // which will always fit in a uint40. Thus the result of the multiplication will always fit inside a uint256 and never overflow 87 | // This however introduces a new invariant: the only operations that can be done with amountPerSec/totalPaidPerSec are muls against timestamps 88 | // and we need to make sure they happen in uint256 contexts, not any other 89 | } 90 | 91 | function createStream(address to, uint216 amountPerSec) public { 92 | bytes32 streamId = _createStream(to, amountPerSec); 93 | emit StreamCreated(msg.sender, to, amountPerSec, streamId); 94 | } 95 | 96 | function createStreamWithReason(address to, uint216 amountPerSec, string calldata reason) public { 97 | bytes32 streamId = _createStream(to, amountPerSec); 98 | emit StreamCreatedWithReason(msg.sender, to, amountPerSec, streamId, reason); 99 | } 100 | 101 | /* 102 | proof that lastUpdate < block.timestamp: 103 | 104 | let's start by assuming the opposite, that lastUpdate > block.timestamp, and then we'll prove that this is impossible 105 | lastUpdate > block.timestamp 106 | -> timePaid = lastUpdate - lastPayerUpdate[from] > block.timestamp - lastPayerUpdate[from] = payerDelta 107 | -> timePaid > payerDelta 108 | -> payerBalance = timePaid * totalPaidPerSec[from] > payerDelta * totalPaidPerSec[from] = totalPayerPayment 109 | -> payerBalance > totalPayerPayment 110 | but this last statement is impossible because if it were true we'd have gone into the first if branch! 111 | */ 112 | /* 113 | proof that totalPaidPerSec[from] != 0: 114 | 115 | totalPaidPerSec[from] is a sum of uint that are different from zero (since we test that on createStream()) 116 | and we test that there's at least one stream active with `streamToStart[streamId] != 0`, 117 | so it's a sum of one or more elements that are higher than zero, thus it can never be zero 118 | */ 119 | 120 | // Make it possible to withdraw on behalf of others, important for people that don't have a metamask wallet (eg: cex address, trustwallet...) 121 | function _withdraw(address from, address to, uint216 amountPerSec) private returns (uint40 lastUpdate, bytes32 streamId, uint amountToTransfer) { 122 | streamId = getStreamId(from, to, amountPerSec); 123 | require(streamToStart[streamId] != 0, "stream doesn't exist"); 124 | 125 | Payer storage payer = payers[from]; 126 | uint totalPayerPayment; 127 | uint payerDelta = block.timestamp - payer.lastPayerUpdate; 128 | unchecked{ 129 | totalPayerPayment = payerDelta * uint(payer.totalPaidPerSec); 130 | } 131 | uint payerBalance = balances[from]; 132 | if(payerBalance >= totalPayerPayment){ 133 | unchecked { 134 | balances[from] = payerBalance - totalPayerPayment; 135 | } 136 | lastUpdate = uint40(block.timestamp); 137 | } else { 138 | // invariant: totalPaidPerSec[from] != 0 139 | unchecked { 140 | uint timePaid = payerBalance/uint(payer.totalPaidPerSec); 141 | lastUpdate = uint40(payer.lastPayerUpdate + timePaid); 142 | // invariant: lastUpdate < block.timestamp (we need to maintain it) 143 | balances[from] = payerBalance % uint(payer.totalPaidPerSec); 144 | } 145 | } 146 | uint delta = lastUpdate - streamToStart[streamId]; // Could use unchecked here too I think 147 | unchecked { 148 | // We push transfers to be done outside this function and at the end of public functions to avoid reentrancy exploits 149 | amountToTransfer = (delta*uint(amountPerSec))/DECIMALS_DIVISOR; 150 | } 151 | emit Withdraw(from, to, amountPerSec, streamId, amountToTransfer); 152 | } 153 | 154 | // Copy of _withdraw that is view-only and returns how much can be withdrawn from a stream, purely for convenience on frontend 155 | // No need to review since this does nothing 156 | function withdrawable(address from, address to, uint216 amountPerSec) external view returns (uint withdrawableAmount, uint lastUpdate, uint owed) { 157 | bytes32 streamId = getStreamId(from, to, amountPerSec); 158 | require(streamToStart[streamId] != 0, "stream doesn't exist"); 159 | 160 | Payer storage payer = payers[from]; 161 | uint totalPayerPayment; 162 | uint payerDelta = block.timestamp - payer.lastPayerUpdate; 163 | unchecked{ 164 | totalPayerPayment = payerDelta * uint(payer.totalPaidPerSec); 165 | } 166 | uint payerBalance = balances[from]; 167 | if(payerBalance >= totalPayerPayment){ 168 | lastUpdate = block.timestamp; 169 | } else { 170 | unchecked { 171 | uint timePaid = payerBalance/uint(payer.totalPaidPerSec); 172 | lastUpdate = payer.lastPayerUpdate + timePaid; 173 | } 174 | } 175 | uint delta = lastUpdate - streamToStart[streamId]; 176 | withdrawableAmount = (delta*uint(amountPerSec))/DECIMALS_DIVISOR; 177 | owed = ((block.timestamp - lastUpdate)*uint(amountPerSec))/DECIMALS_DIVISOR; 178 | } 179 | 180 | function withdraw(address from, address to, uint216 amountPerSec) external { 181 | (uint40 lastUpdate, bytes32 streamId, uint amountToTransfer) = _withdraw(from, to, amountPerSec); 182 | streamToStart[streamId] = lastUpdate; 183 | payers[from].lastPayerUpdate = lastUpdate; 184 | token.safeTransfer(to, amountToTransfer); 185 | } 186 | 187 | function _cancelStream(address to, uint216 amountPerSec) internal returns (bytes32 streamId) { 188 | uint40 lastUpdate; uint amountToTransfer; 189 | (lastUpdate, streamId, amountToTransfer) = _withdraw(msg.sender, to, amountPerSec); 190 | streamToStart[streamId] = 0; 191 | Payer storage payer = payers[msg.sender]; 192 | unchecked{ 193 | // totalPaidPerSec is a sum of items which include amountPerSec, so totalPaidPerSec >= amountPerSec 194 | payer.totalPaidPerSec -= amountPerSec; 195 | } 196 | payer.lastPayerUpdate = lastUpdate; 197 | token.safeTransfer(to, amountToTransfer); 198 | } 199 | 200 | function cancelStream(address to, uint216 amountPerSec) public { 201 | bytes32 streamId = _cancelStream(to, amountPerSec); 202 | emit StreamCancelled(msg.sender, to, amountPerSec, streamId); 203 | } 204 | 205 | function pauseStream(address to, uint216 amountPerSec) public { 206 | bytes32 streamId = _cancelStream(to, amountPerSec); 207 | emit StreamPaused(msg.sender, to, amountPerSec, streamId); 208 | } 209 | 210 | function modifyStream(address oldTo, uint216 oldAmountPerSec, address to, uint216 amountPerSec) external { 211 | // Can be optimized but I don't think extra complexity is worth it 212 | bytes32 oldStreamId = _cancelStream(oldTo, oldAmountPerSec); 213 | bytes32 newStreamId = _createStream(to, amountPerSec); 214 | emit StreamModified(msg.sender, oldTo, oldAmountPerSec, oldStreamId, to, amountPerSec, newStreamId); 215 | } 216 | 217 | function deposit(uint amount) public { 218 | balances[msg.sender] += amount * DECIMALS_DIVISOR; 219 | token.safeTransferFrom(msg.sender, address(this), amount); 220 | emit PayerDeposit(msg.sender, amount); 221 | } 222 | 223 | function depositAndCreate(uint amountToDeposit, address to, uint216 amountPerSec) external { 224 | deposit(amountToDeposit); 225 | createStream(to, amountPerSec); 226 | } 227 | 228 | function depositAndCreateWithReason(uint amountToDeposit, address to, uint216 amountPerSec, string calldata reason) external { 229 | deposit(amountToDeposit); 230 | createStreamWithReason(to, amountPerSec, reason); 231 | } 232 | 233 | function withdrawPayer(uint amount) public { 234 | Payer storage payer = payers[msg.sender]; 235 | balances[msg.sender] -= amount; // implicit check that balance > amount 236 | uint delta = block.timestamp - payer.lastPayerUpdate; 237 | unchecked { 238 | require(balances[msg.sender] >= delta*uint(payer.totalPaidPerSec), "pls no rug"); 239 | uint tokenAmount = amount/DECIMALS_DIVISOR; 240 | token.safeTransfer(msg.sender, tokenAmount); 241 | emit PayerWithdraw(msg.sender, tokenAmount); 242 | } 243 | } 244 | 245 | function withdrawPayerAll() external { 246 | Payer storage payer = payers[msg.sender]; 247 | unchecked { 248 | uint delta = block.timestamp - payer.lastPayerUpdate; 249 | // Just helper function, nothing happens if number is wrong 250 | // If there's an overflow it's just equivalent to calling withdrawPayer() directly with a big number 251 | withdrawPayer(balances[msg.sender]-delta*uint(payer.totalPaidPerSec)); 252 | } 253 | } 254 | 255 | function getPayerBalance(address payerAddress) external view returns (int) { 256 | Payer storage payer = payers[payerAddress]; 257 | int balance = int(balances[payerAddress]); 258 | uint delta = block.timestamp - payer.lastPayerUpdate; 259 | return (balance - int(delta*uint(payer.totalPaidPerSec)))/int(DECIMALS_DIVISOR); 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /contracts/LlamaPayFactory.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: None 2 | pragma solidity ^0.8.0; 3 | 4 | import {LlamaPay} from "./LlamaPay.sol"; 5 | 6 | 7 | contract LlamaPayFactory { 8 | bytes32 constant INIT_CODEHASH = keccak256(type(LlamaPay).creationCode); 9 | 10 | address public parameter; 11 | uint256 public getLlamaPayContractCount; 12 | address[1000000000] public getLlamaPayContractByIndex; // 1 billion indices 13 | 14 | event LlamaPayCreated(address token, address llamaPay); 15 | 16 | /** 17 | @notice Create a new Llama Pay Streaming instance for `_token` 18 | @dev Instances are created deterministically via CREATE2 and duplicate 19 | instances will cause a revert 20 | @param _token The ERC20 token address for which a Llama Pay contract should be deployed 21 | @return llamaPayContract The address of the newly created Llama Pay contract 22 | */ 23 | function createLlamaPayContract(address _token) external returns (address llamaPayContract) { 24 | // set the parameter storage slot so the contract can query it 25 | parameter = _token; 26 | // use CREATE2 so we can get a deterministic address based on the token 27 | llamaPayContract = address(new LlamaPay{salt: bytes32(uint256(uint160(_token)))}()); 28 | // CREATE2 can return address(0), add a check to verify this isn't the case 29 | // See: https://eips.ethereum.org/EIPS/eip-1014 30 | require(llamaPayContract != address(0)); 31 | 32 | // Append the new contract address to the array of deployed contracts 33 | uint256 index = getLlamaPayContractCount; 34 | getLlamaPayContractByIndex[index] = llamaPayContract; 35 | unchecked{ 36 | getLlamaPayContractCount = index + 1; 37 | } 38 | 39 | emit LlamaPayCreated(_token, llamaPayContract); 40 | } 41 | 42 | /** 43 | @notice Query the address of the Llama Pay contract for `_token` and whether it is deployed 44 | @param _token An ERC20 token address 45 | @return predictedAddress The deterministic address where the llama pay contract will be deployed for `_token` 46 | @return isDeployed Boolean denoting whether the contract is currently deployed 47 | */ 48 | function getLlamaPayContractByToken(address _token) external view returns(address predictedAddress, bool isDeployed){ 49 | predictedAddress = address(uint160(uint256(keccak256(abi.encodePacked( 50 | bytes1(0xff), 51 | address(this), 52 | bytes32(uint256(uint160(_token))), 53 | INIT_CODEHASH 54 | ))))); 55 | isDeployed = predictedAddress.code.length != 0; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /contracts/fork/BoringBatchable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | pragma experimental ABIEncoderV2; 4 | 5 | // solhint-disable avoid-low-level-calls 6 | // solhint-disable no-inline-assembly 7 | 8 | // WARNING!!! 9 | // Combining BoringBatchable with msg.value can cause double spending issues 10 | // https://www.paradigm.xyz/2021/08/two-rights-might-make-a-wrong/ 11 | 12 | interface IERC20Permit{ 13 | /// @notice EIP 2612 14 | function permit( 15 | address owner, 16 | address spender, 17 | uint256 value, 18 | uint256 deadline, 19 | uint8 v, 20 | bytes32 r, 21 | bytes32 s 22 | ) external; 23 | } 24 | 25 | contract BaseBoringBatchable { 26 | /// @dev Helper function to extract a useful revert message from a failed call. 27 | /// If the returned data is malformed or not correctly abi encoded then this call can fail itself. 28 | function _getRevertMsg(bytes memory _returnData) internal pure returns (string memory) { 29 | // If the _res length is less than 68, then the transaction failed silently (without a revert message) 30 | if (_returnData.length < 68) return "Transaction reverted silently"; 31 | 32 | assembly { 33 | // Slice the sighash. 34 | _returnData := add(_returnData, 0x04) 35 | } 36 | return abi.decode(_returnData, (string)); // All that remains is the revert string 37 | } 38 | 39 | /// @notice Allows batched call to self (this contract). 40 | /// @param calls An array of inputs for each call. 41 | /// @param revertOnFail If True then reverts after a failed call and stops doing further calls. 42 | // F1: External is ok here because this is the batch function, adding it to a batch makes no sense 43 | // F2: Calls in the batch may be payable, delegatecall operates in the same context, so each call in the batch has access to msg.value 44 | // C3: The length of the loop is fully under user control, so can't be exploited 45 | // C7: Delegatecall is only used on the same contract, so it's safe 46 | function batch(bytes[] calldata calls, bool revertOnFail) external payable { 47 | for (uint256 i = 0; i < calls.length; i++) { 48 | (bool success, bytes memory result) = address(this).delegatecall(calls[i]); 49 | if (!success && revertOnFail) { 50 | revert(_getRevertMsg(result)); 51 | } 52 | } 53 | } 54 | } 55 | 56 | contract BoringBatchable is BaseBoringBatchable { 57 | /// @notice Call wrapper that performs `ERC20.permit` on `token`. 58 | /// Lookup `IERC20.permit`. 59 | // F6: Parameters can be used front-run the permit and the user's permit will fail (due to nonce or other revert) 60 | // if part of a batch this could be used to grief once as the second call would not need the permit 61 | function permitToken( 62 | IERC20Permit token, 63 | address from, 64 | address to, 65 | uint256 amount, 66 | uint256 deadline, 67 | uint8 v, 68 | bytes32 r, 69 | bytes32 s 70 | ) public { 71 | token.permit(from, to, amount, deadline, v, r, s); 72 | } 73 | } -------------------------------------------------------------------------------- /contracts/mock/MockToken.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: None 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockToken is ERC20 { 7 | uint8 decimals_; 8 | constructor(uint8 decimals__) ERC20("a", "b") { 9 | decimals_ = decimals__; 10 | _mint(msg.sender, 2**255-1); 11 | } 12 | 13 | function decimals() public view override returns (uint8) { 14 | return decimals_; 15 | } 16 | } -------------------------------------------------------------------------------- /deploy/001_factory.ts: -------------------------------------------------------------------------------- 1 | import {HardhatRuntimeEnvironment} from 'hardhat/types'; 2 | import {DeployFunction} from 'hardhat-deploy/types'; 3 | 4 | const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 5 | const {deployments, getNamedAccounts} = hre as any; 6 | const {deploy} = deployments; 7 | 8 | const {deployer} = await getNamedAccounts(); 9 | 10 | await deploy('LlamaPayFactory', { 11 | from: deployer, 12 | args: [], 13 | log: true, 14 | autoMine: true, // speed up deployment on local network (ganache, hardhat), no effect on live networks 15 | deterministicDeployment: true, 16 | }); 17 | }; 18 | export default func; 19 | func.tags = ['LlamaPayFactory']; -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | 3 | import { HardhatUserConfig, task } from "hardhat/config"; 4 | import "@nomiclabs/hardhat-etherscan"; 5 | import "@nomiclabs/hardhat-waffle"; 6 | import "@typechain/hardhat"; 7 | import "hardhat-gas-reporter"; 8 | import "solidity-coverage"; 9 | import 'hardhat-deploy'; 10 | 11 | dotenv.config(); 12 | 13 | // This is a sample Hardhat task. To learn how to create your own go to 14 | // https://hardhat.org/guides/create-task.html 15 | task("accounts", "Prints the list of accounts", async (taskArgs, hre) => { 16 | const accounts = await hre.ethers.getSigners(); 17 | 18 | for (const account of accounts) { 19 | console.log(account.address); 20 | } 21 | }); 22 | 23 | // You need to export an object to set up your config 24 | // Go to https://hardhat.org/config/ to learn more 25 | 26 | const config: HardhatUserConfig = { 27 | solidity: { 28 | version: "0.8.4", 29 | ...(process.env.DEPLOY === "true" && 30 | { 31 | settings: { 32 | optimizer: { 33 | enabled: true, 34 | runs: 1000, 35 | }, 36 | }, 37 | } 38 | ) 39 | }, 40 | namedAccounts: { 41 | deployer: 0, 42 | }, 43 | networks: { 44 | ropsten: { 45 | url: process.env.ROPSTEN_URL || "", 46 | accounts: 47 | process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [], 48 | }, 49 | rinkeby: { 50 | url: process.env.RINKEBY_RPC, 51 | accounts: [process.env.PRIVATEKEY!] 52 | }, 53 | kovan: { 54 | url: "https://kovan.poa.network", 55 | accounts: [process.env.PRIVATEKEY!], 56 | gasMultiplier: 1.5, 57 | }, 58 | hardhat: { 59 | forking: { 60 | url: process.env.ETH_RPC! 61 | } 62 | }, 63 | }, 64 | gasReporter: { 65 | enabled: process.env.REPORT_GAS !== undefined, 66 | currency: "USD", 67 | //gasPrice: 100, 68 | coinmarketcap: process.env.CMC_API_KEY 69 | }, 70 | etherscan: { 71 | apiKey: process.env.ETHERSCAN, 72 | }, 73 | }; 74 | 75 | export default config; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "llamapay", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "private": true, 7 | "scripts": { 8 | "test": "hardhat test" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@nomiclabs/hardhat-ethers": "^2.0.5", 15 | "@nomiclabs/hardhat-etherscan": "^3.0.3", 16 | "@nomiclabs/hardhat-waffle": "^2.0.3", 17 | "@typechain/ethers-v5": "^7.2.0", 18 | "@typechain/hardhat": "^2.3.1", 19 | "@types/chai": "^4.3.0", 20 | "@types/mocha": "^9.1.0", 21 | "@types/node": "^12.20.46", 22 | "@typescript-eslint/eslint-plugin": "^4.33.0", 23 | "@typescript-eslint/parser": "^4.33.0", 24 | "chai": "^4.3.6", 25 | "dotenv": "^10.0.0", 26 | "eslint": "^7.32.0", 27 | "eslint-config-prettier": "^8.5.0", 28 | "eslint-config-standard": "^16.0.3", 29 | "eslint-plugin-import": "^2.25.4", 30 | "eslint-plugin-node": "^11.1.0", 31 | "eslint-plugin-prettier": "^3.4.1", 32 | "eslint-plugin-promise": "^5.2.0", 33 | "ethereum-waffle": "^3.4.0", 34 | "ethers": "^5.5.4", 35 | "hardhat": "^2.9.0", 36 | "hardhat-deploy": "^0.11.0", 37 | "hardhat-gas-reporter": "^1.0.8", 38 | "prettier": "^2.5.1", 39 | "prettier-plugin-solidity": "^1.0.0-beta.13", 40 | "solhint": "^3.3.7", 41 | "solidity-coverage": "^0.7.20", 42 | "ts-node": "^10.6.0", 43 | "typechain": "^5.2.0", 44 | "typescript": "^4.6.2" 45 | }, 46 | "dependencies": { 47 | "@openzeppelin/contracts": "^4.5.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "hardhat"; 2 | import {deploy} from "../test/utils" 3 | 4 | const tokens = ["0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa", "0xd0A1E359811322d97991E03f863a0C30C2cF029C"] 5 | 6 | async function main() { 7 | const llamaPayFactory = await deploy("LlamaPayFactory") 8 | console.log("LlamaPayFactory deployed to:", llamaPayFactory.address); 9 | for(const token of tokens){ 10 | await llamaPayFactory.createPayContract(token); 11 | console.log(`llamapay deployed to ${await llamaPayFactory.payContracts(token)}`) 12 | } 13 | } 14 | 15 | // We recommend this pattern to be able to use async/await everywhere 16 | // and properly handle errors. 17 | main().catch((error) => { 18 | console.error(error); 19 | process.exitCode = 1; 20 | }); 21 | -------------------------------------------------------------------------------- /test/factory.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import {deployAll} from './utils' 4 | 5 | describe("Factory", function () { 6 | it("can't create the an instance for the same token twice", async function () { 7 | const { llamaPay, llamaPayFactory, token } = await deployAll({}) 8 | await expect( 9 | llamaPayFactory.createLlamaPayContract(token.address) 10 | ).to.be.revertedWith(""); 11 | }); 12 | 13 | it("array works", async function () { 14 | let tokens = [ 15 | "0xdac17f958d2ee523a2206206994597c13d831ec7", 16 | "0xB8c77482e45F1F44dE1745F52C74426C631bDD52", 17 | "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 18 | "0x2b591e99afe9f32eaa6214f7b7629768c40eeb39" 19 | ] 20 | const { llamaPay, llamaPayFactory, token } = await deployAll({}); 21 | for(const tokenAddress of tokens){ 22 | expect((await llamaPayFactory.getLlamaPayContractByToken(tokenAddress)).isDeployed).to.equal(false) 23 | await llamaPayFactory.createLlamaPayContract(tokenAddress); 24 | } 25 | expect(await llamaPayFactory.getLlamaPayContractCount()).to.equal((tokens.length+1).toString()) 26 | tokens = [token.address].concat(tokens) 27 | for(let i =0; i20 decimals", async function () { 61 | await setupStream(1e6, 20) 62 | await expect(setupStream(1e6, 21)).to.be.revertedWith("") 63 | }) 64 | it("there's no big precision errors when the token has high decimals (18)", async function () { 65 | const {totalPaid} = await setupStreamAndWithdraw(1e6, 18) 66 | expect((totalPaid/10**18).toFixed(decimalsPrecision)).to.equal((1e6).toFixed(decimalsPrecision)) 67 | }); 68 | it("there's no big precision errors when the token has low decimals (6)", async function () { 69 | const {totalPaid} = await setupStreamAndWithdraw(1e3, 6) 70 | expect((totalPaid/10**6).toFixed(decimalsPrecision)).to.equal(1e3.toFixed(decimalsPrecision)) 71 | }); 72 | it("can't withdraw on a cancelled stream", async ()=>{ 73 | const {payer, payee, llamaPay, perSec} = await setupStream(5e3); 74 | await llamaPay.cancelStream(payee.address, perSec) 75 | await expect(llamaPay.withdraw(payer.address, payee.address, perSec)).to.be.revertedWith("stream doesn't exist"); 76 | }) 77 | it("withdrawPayer works and if withdraw is called after less than perSec funds are left in contract", async ()=>{ 78 | const {payer, payee, llamaPay, token, perSec} = await setupStream(10e3); 79 | await llamaPay.withdrawPayer(5e3*1e3); 80 | const left = await llamaPay.getPayerBalance(payer.address) 81 | expect(left).to.be.gt(bg(9.9999e3*1e18)); 82 | await llamaPay.withdrawPayerAll(); 83 | const left2 = await llamaPay.getPayerBalance(payer.address) 84 | expect(left2).to.be.eq("0"); // negative because some seconds have gone since withdrawal 85 | expect(left2).to.be.gt(perSec.mul(-1)); 86 | await llamaPay.withdraw(payer.address, payee.address, perSec); 87 | expect(await token.balanceOf(llamaPay.address)).to.be.lt(perSec) 88 | }) 89 | it("if withdrawPayer is called after stream withdrawal then almost no tokens are left in contract", async ()=>{ 90 | const {payer, payee, llamaPay, token, perSec} = await setupStream(10e3); 91 | await llamaPay.cancelStream(payee.address, perSec) 92 | await llamaPay.withdrawPayerAll(); 93 | expect(await token.balanceOf(llamaPay.address)).to.equal("0") 94 | }) 95 | it("modifyStream", async ()=>{ 96 | const {payer, payee, llamaPay, perSec, payee2} = await setupStream(10e3); 97 | const streamId = await llamaPay.getStreamId(payer.address, payee.address, perSec); 98 | const statusBefore = await llamaPay.streamToStart(streamId) 99 | expect(statusBefore).not.to.eq("0") 100 | await llamaPay.modifyStream(payee.address, perSec, payee2.address, 20); 101 | const statusAfter = await llamaPay.streamToStart(streamId) 102 | expect(statusAfter).to.eq("0") 103 | }) 104 | it("standard flow with multiple payees and payers", async ()=>{ 105 | const {llamaPay, token, DECIMALS_DIVISOR} = await basicSetup(); 106 | const [owner, payer, payee, payee2] = await ethers.getSigners(); 107 | const total = bg(10e3*1e18) // 10k 108 | await token.transfer(payer.address, total.mul(10)) 109 | await token.connect(payer).approve(llamaPay.address, total.mul(10)) 110 | const monthly1k = total.div(10).div(MONTH).mul(DECIMALS_DIVISOR) // 1k 111 | await llamaPay.connect(payer).depositAndCreate(total, payee.address, monthly1k.mul(5)) // 10k deposited 112 | await advanceTime(MONTH/2) // 2.5k paid 113 | await llamaPay.connect(payer).createStream(payee2.address, monthly1k.mul(10)) // 10k 114 | await llamaPay.connect(payee2).withdraw(payer.address, payee.address, monthly1k.mul(5)) 115 | await balanceIs(token, payee.address, 2.5e3); 116 | await advanceTime(MONTH) // 7.5k + 10k paid 117 | await llamaPay.withdraw(payer.address, payee.address, monthly1k.mul(5)) // can only withdraw up to 2.5k 118 | await balanceIs(token, payee.address, 5e3); 119 | await llamaPay.withdraw(payer.address, payee2.address, monthly1k.mul(10)) 120 | await balanceIs(token, payee2.address, 5e3); 121 | 122 | // attempt withdrawal again 123 | const prevBal = await token.balanceOf(payee.address); 124 | await llamaPay.withdraw(payer.address, payee.address, monthly1k.mul(5)) 125 | const afterBal = await token.balanceOf(payee.address); 126 | 127 | expect(prevBal).to.equal(afterBal); 128 | // payer tries to steal by creating a new stream while in debt 129 | // Can't create new streams until debt is paid 130 | expect(llamaPay.connect(payer).createStream(payee2.address, monthly1k.mul(100))).to.be.revertedWith("aaaa") 131 | // Can't withdraw if there's debt 132 | expect(llamaPay.connect(payer).withdrawPayer("1")).to.be.revertedWith("aaaa") 133 | const payerBal = await llamaPay.getPayerBalance(payer.address) 134 | sameNum(payerBal, -7.5e3, 1); // 7.5k owed 135 | const withdrawable1 = await llamaPay.withdrawable(payer.address, payee2.address, monthly1k.mul(10)) 136 | expect(withdrawable1.withdrawableAmount).to.eq(0) 137 | sameNum(withdrawable1.owed, 5e3, 1) 138 | 139 | await llamaPay.connect(payer).deposit(bg(1e3*1e18)) 140 | const withdrawable2 = await llamaPay.withdrawable(payer.address, payee2.address, monthly1k.mul(10)) 141 | sameNum(withdrawable2.withdrawableAmount, 666.6666, 1) 142 | sameNum(withdrawable2.owed, 4.333333e3, 0) 143 | 144 | const withdrawablePayee1 = await llamaPay.withdrawable(payer.address, payee.address, monthly1k.mul(5)) 145 | sameNum(withdrawablePayee1.withdrawableAmount, 333.333, 1) 146 | sameNum(withdrawablePayee1.owed, 2.5e3-333.3, 0) 147 | 148 | // payer rugs first payee by cancelling their stream 149 | await llamaPay.connect(payer).cancelStream(payee.address, monthly1k.mul(5)) 150 | await balanceIs(token, payee.address, 5e3+333.33); 151 | expect(llamaPay.withdraw(payer.address, payee.address, monthly1k.mul(5))).to.be.revertedWith("aaaa") // can't withdraw from stream anymore 152 | const withdrawablePayee2AfterCancel = await llamaPay.withdrawable(payer.address, payee2.address, monthly1k.mul(10)) 153 | sameNum(withdrawablePayee2AfterCancel.owed, Number(withdrawable2.owed)/1e18, 1) 154 | 155 | // extra debt from payee 2 is cancelled 156 | sameNum(await llamaPay.getPayerBalance(payer.address), -4.333e3, 0); 157 | 158 | await llamaPay.connect(payer).deposit(bg(10e3*1e18)) // payer deposits 10k 159 | sameNum(await llamaPay.getPayerBalance(payer.address), 5.66666e3, 0); // 7.5k owed 160 | 161 | const withdrawablePayee2Again = await llamaPay.withdrawable(payer.address, payee2.address, monthly1k.mul(10)) 162 | sameNum(withdrawablePayee2Again.withdrawableAmount, 5e3, 0); 163 | 164 | await llamaPay.withdraw(payer.address, payee2.address, monthly1k.mul(10)) 165 | await balanceIs(token, payee2.address, 10000.04); 166 | }) 167 | it("overflow triggered on totalPaidPerSec", async ()=>{ 168 | const {payee, llamaPay, payee2} = await basicSetup(); 169 | const perSecOverflow = BigNumber.from(2).pow(216).sub(5); 170 | await llamaPay.deposit(perSecOverflow.mul(10)) 171 | await llamaPay.createStream(payee.address, perSecOverflow) 172 | await llamaPay.createStream(payee2.address, "1") 173 | await llamaPay.createStream(payee2.address, "2") 174 | await expect(llamaPay.createStream(payee2.address, "5")).to.be.revertedWith(""); 175 | }) 176 | it("can't overwrite stream", async ()=>{ 177 | const {payee, llamaPay} = await basicSetup(); 178 | const perSec = "100000000000" 179 | llamaPay.createStream(payee.address, perSec) 180 | await expect(llamaPay.createStream(payee.address, perSec)).to.be.revertedWith("stream already exists"); 181 | }) 182 | it("can't create stream with 0 payment", async ()=>{ 183 | const {payee, llamaPay} = await basicSetup(); 184 | await expect(llamaPay.createStream(payee.address, "0")).to.be.revertedWith("amountPerSec can't be 0"); 185 | }) 186 | it("can't cancel non-existent stream", async ()=>{ 187 | const {payee, llamaPay} = await basicSetup(); 188 | await expect(llamaPay.cancelStream(payee.address, "1")).to.be.revertedWith("stream doesn't exist"); 189 | }) 190 | }) -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "hardhat"; 2 | 3 | export async function deploy(name: string, args: string[] = []) { 4 | const Contract = await ethers.getContractFactory(name); 5 | const contract = await Contract.deploy(...args); 6 | await contract.deployed(); 7 | return contract 8 | } 9 | 10 | export async function deployAll({ 11 | tokenDecimals = 18 12 | }) { 13 | const mockToken = await deploy("MockToken", [tokenDecimals.toString()]); 14 | const llamaPayFactory = await deploy("LlamaPayFactory") 15 | await llamaPayFactory.createLlamaPayContract(mockToken.address) 16 | 17 | const llamaPayAddress = (await llamaPayFactory.getLlamaPayContractByToken(mockToken.address))[0]; 18 | const LlamaPay = await ethers.getContractFactory("LlamaPay"); 19 | const llamaPay = await LlamaPay.attach(llamaPayAddress) 20 | return { llamaPay, llamaPayFactory, token: mockToken } 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "declaration": true 9 | }, 10 | "include": ["./scripts", "./test", "./typechain"], 11 | "files": ["./hardhat.config.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /v2/Adapter.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: None 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | 6 | abstract contract Adapter { 7 | function deposit(address vault, uint256 amount) public virtual; 8 | function withdraw(address vault, uint256 amount) public virtual; 9 | function pricePerShare(address vault) public view virtual returns (uint256); 10 | function refreshSetup(address token, address vault) public virtual { 11 | IERC20(token).approve(vault, type(uint).max); 12 | } 13 | } 14 | 15 | interface YearnVault { 16 | function deposit(uint256 _amount) external; 17 | function withdraw(uint256 maxShares) external; 18 | function pricePerShare() external view returns (uint); 19 | } 20 | 21 | contract YearnAdapter is Adapter { 22 | function deposit(address vault, uint256 amount) public override { 23 | YearnVault(vault).deposit(amount); 24 | } 25 | 26 | function withdraw(address vault, uint256 amount) public override { 27 | YearnVault(vault).withdraw(amount); 28 | } 29 | 30 | function pricePerShare(address vault) public view override returns (uint256){ 31 | return YearnVault(vault).pricePerShare(); 32 | } 33 | } 34 | 35 | interface BeefyVault { 36 | function deposit(uint256 _amount) external; 37 | function withdraw(uint256 maxShares) external; 38 | function getPricePerFullShare() external view returns (uint); 39 | } 40 | 41 | contract BeefyAdapter is Adapter { 42 | function deposit(address vault, uint256 amount) public override { 43 | BeefyVault(vault).deposit(amount); 44 | } 45 | 46 | function withdraw(address vault, uint256 amount) public override { 47 | BeefyVault(vault).withdraw(amount); 48 | } 49 | 50 | function pricePerShare(address vault) public view override returns (uint256){ 51 | return BeefyVault(vault).getPricePerFullShare(); 52 | } 53 | } 54 | 55 | interface CompoundToken { 56 | function mint(uint256 mintAmount) external; 57 | function redeem(uint256 redeemTokens) external; 58 | function exchangeRateStored() external view returns (uint); 59 | } 60 | 61 | contract CompoundAdapter is Adapter { 62 | function deposit(address vault, uint256 amount) public override { 63 | CompoundToken(vault).mint(amount); 64 | } 65 | 66 | function withdraw(address vault, uint256 amount) public override { 67 | CompoundToken(vault).redeem(amount); 68 | } 69 | 70 | function pricePerShare(address vault) public view override returns (uint256){ 71 | return CompoundToken(vault).exchangeRateStored(); 72 | } 73 | } -------------------------------------------------------------------------------- /v2/LlamaPay.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: None 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | import "@openzeppelin/contracts/access/Ownable.sol"; 6 | import "./Adapter.sol"; 7 | 8 | contract LlamaPay is Ownable { 9 | mapping (bytes32 => uint) public streamToStart; 10 | mapping (address => uint) public totalPaidPerSec; 11 | mapping (address => uint) public lastPayerUpdate; 12 | mapping (address => uint) public balances; 13 | mapping (address => uint) public yieldEarnedPerToken; 14 | mapping (address => uint) public paidBalance; 15 | mapping (address => uint) public lastPricePerShare; 16 | IERC20 immutable public token; 17 | address immutable public vault; 18 | address immutable public adapter; 19 | 20 | constructor(address _token, address _adapter, address _vault){ 21 | token = IERC20(_token); 22 | adapter = _adapter; 23 | vault = _vault; 24 | _refreshSetup(_adapter, _token, _vault); 25 | } 26 | 27 | function getStreamId(address from, address to, uint amountPerSec) public pure returns (bytes32){ 28 | return keccak256(abi.encodePacked(from, to, amountPerSec)); 29 | } 30 | 31 | function getPricePerShare() private view returns (uint) { 32 | return Adapter(adapter).pricePerShare(vault); 33 | } 34 | 35 | function updateBalances(address payer) private { 36 | uint delta; 37 | unchecked { 38 | delta = block.timestamp - lastPayerUpdate[payer]; 39 | } 40 | uint totalPaid = delta * totalPaidPerSec[payer]; 41 | balances[payer] -= totalPaid; 42 | lastPayerUpdate[payer] = block.timestamp; 43 | 44 | uint lastPrice = lastPricePerShare[payer]; 45 | uint currentPrice = Adapter(adapter).pricePerShare(vault); 46 | if(lastPrice == 0){ 47 | lastPrice = currentPrice; 48 | } 49 | if(currentPrice >= lastPrice) { 50 | // no need to worry about currentPrice = 0 because that means that all money is gone 51 | balances[payer] = (balances[payer]*currentPrice)/lastPrice; 52 | uint profitsFromPaid = ((totalPaid*currentPrice)/lastPrice - totalPaid)/2; // assumes profits are strictly increasing 53 | balances[payer] += profitsFromPaid; 54 | uint yieldOnOldCoins = ((paidBalance[payer]*currentPrice)/lastPrice) - paidBalance[payer]; 55 | yieldEarnedPerToken[payer] += (profitsFromPaid + yieldOnOldCoins)/paidBalance[payer]; 56 | paidBalance[payer] += yieldOnOldCoins + profitsFromPaid + totalPaid; 57 | lastPricePerShare[payer] = currentPrice; 58 | } 59 | } 60 | 61 | function createStream(address to, uint amountPerSec) public { 62 | bytes32 streamId = getStreamId(msg.sender, to, amountPerSec); 63 | // this checks that even if: 64 | // - token has 18 decimals 65 | // - each person earns 10B/yr 66 | // - each person will be earning for 1000 years 67 | // - there are 1B people earning (requires 1B txs) 68 | // there won't be an overflow in all those 1k years 69 | // checking for overflow is important because if there's an overflow later money will be stuck forever as all txs will revert 70 | unchecked { 71 | require(amountPerSec < type(uint).max/(10e9 * 1e3 * 365 days * 1e9), "no overflow"); 72 | } 73 | require(amountPerSec > 0, "amountPerSec can't be 0"); 74 | require(streamToStart[streamId] == 0, "stream already exists"); 75 | streamToStart[streamId] = block.timestamp; 76 | updateBalances(msg.sender); // can't create a new stream unless there's no debt 77 | totalPaidPerSec[msg.sender] += amountPerSec; 78 | } 79 | 80 | function cancelStream(address to, uint amountPerSec) public { 81 | withdraw(msg.sender, to, amountPerSec); 82 | bytes32 streamId = getStreamId(msg.sender, to, amountPerSec); 83 | streamToStart[streamId] = 0; 84 | unchecked{ 85 | totalPaidPerSec[msg.sender] -= amountPerSec; 86 | } 87 | } 88 | 89 | // Make it possible to withdraw on behalf of others, important for people that don't have a metamask wallet (eg: cex address, trustwallet...) 90 | function withdraw(address from, address to, uint amountPerSec) public { 91 | bytes32 streamId = getStreamId(from, to, amountPerSec); 92 | require(streamToStart[streamId] != 0, "stream doesn't exist"); 93 | 94 | uint payerDelta = block.timestamp - lastPayerUpdate[from]; 95 | uint totalPayerPayment = payerDelta * totalPaidPerSec[from]; 96 | uint payerBalance = balances[from]; 97 | if(payerBalance >= totalPayerPayment){ 98 | balances[from] -= totalPayerPayment; 99 | lastPayerUpdate[from] = block.timestamp; 100 | } else { 101 | // invariant: totalPaidPerSec[from] != 0 102 | unchecked { 103 | uint timePaid = payerBalance/totalPaidPerSec[from]; 104 | lastPayerUpdate[from] += timePaid; 105 | // invariant: lastPayerUpdate[from] < block.timestamp 106 | balances[from] = payerBalance % totalPaidPerSec[from]; 107 | } 108 | } 109 | uint lastUpdate = lastPayerUpdate[from]; 110 | uint delta = lastUpdate - streamToStart[streamId]; 111 | streamToStart[streamId] = lastUpdate; 112 | paidBalance[from] -= delta*amountPerSec; 113 | token.transfer(to, delta*amountPerSec); 114 | } 115 | 116 | function modify(address oldTo, uint oldAmountPerSec, address to, uint amountPerSec) external { 117 | cancelStream(oldTo, oldAmountPerSec); 118 | createStream(to, amountPerSec); 119 | } 120 | 121 | function deposit(uint amount) external { 122 | token.transferFrom(msg.sender, address(this), amount); 123 | (bool success,) = adapter.delegatecall( 124 | abi.encodeWithSelector(Adapter.deposit.selector, vault, amount) 125 | ); 126 | require(success, "deposit() failed"); 127 | balances[msg.sender] += amount; 128 | } 129 | 130 | function withdrawPayer(uint amount) external { 131 | balances[msg.sender] -= amount; // implicit check that balance > amount 132 | uint delta; 133 | unchecked { 134 | delta = block.timestamp - lastPayerUpdate[msg.sender]; // timestamps can't go back in time (https://github.com/ethereum/go-ethereum/blob/master/consensus/ethash/consensus.go#L274) 135 | } 136 | require(delta*totalPaidPerSec[msg.sender] >= balances[msg.sender], "pls no rug"); 137 | uint prevBalance = token.balanceOf(address(this)); 138 | withdrawFromVault(amount/lastPricePerShare[msg.sender]); 139 | uint newBalance = token.balanceOf(address(this)); 140 | token.transfer(msg.sender, newBalance-prevBalance); 141 | } 142 | 143 | function withdrawFromVault(uint amount) private { 144 | (bool success,) = adapter.delegatecall( 145 | abi.encodeWithSelector(Adapter.withdraw.selector, vault, amount) 146 | ); 147 | require(success, "refreshSetup() failed"); 148 | } 149 | 150 | function _refreshSetup(address _adapter, address _token, address _vault) private { 151 | (bool success,) = _adapter.delegatecall( 152 | abi.encodeWithSelector(Adapter.refreshSetup.selector, _token, _vault) 153 | ); 154 | require(success, "refreshSetup() failed"); 155 | } 156 | 157 | function refreshSetup() public { 158 | _refreshSetup(adapter, address(token), vault); 159 | } 160 | 161 | // Performs an arbitrary call 162 | // This will be under a heavy timelock and only used in case something goes very wrong (eg: with yield engine) 163 | function emergencyAccess(address target, uint value, bytes memory callData) external onlyOwner { 164 | target.call{value: value}(callData); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /v2/LlamaPayFactory.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: None 2 | pragma solidity ^0.8.0; 3 | 4 | import "hardhat/console.sol"; 5 | import "./LlamaPay.sol"; 6 | 7 | contract LlamaPayFactory { 8 | mapping(address=>mapping(address=>mapping(address => LlamaPay))) public payContracts; 9 | mapping(uint => LlamaPay) public payContractsArray; 10 | uint public payContractsArrayLength; 11 | 12 | event LlamaPayCreated(address token, address adapter, address vault, address llamaPay); 13 | 14 | function createPayContract(address _token, address _adapter, address _vault) external returns (LlamaPay newContract) { 15 | newContract = new LlamaPay(_token, _adapter, _vault); 16 | payContracts[_token][_adapter][_vault] = newContract; 17 | payContractsArray[payContractsArrayLength] = newContract; 18 | unchecked{ 19 | payContractsArrayLength++; 20 | } 21 | emit LlamaPayCreated(_token, _adapter, _vault, address(newContract)); 22 | } 23 | } -------------------------------------------------------------------------------- /v2/test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | async function deploy(name:string, args:string[]=[]){ 5 | const Contract = await ethers.getContractFactory(name); 6 | const contract = await Contract.deploy(...args); 7 | await contract.deployed(); 8 | return contract 9 | } 10 | 11 | describe("LlamaPay", function () { 12 | it("getPricePerShare()", async function () { 13 | const yearnAdapter = await deploy("YearnAdapter"); 14 | const llamaPay = await deploy("LlamaPay", 15 | ["0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", yearnAdapter.address, "0xa258C4606Ca8206D8aA700cE2143D7db854D168c"] 16 | ) 17 | 18 | const price = await llamaPay.getPricePerShare(); 19 | console.log(price); 20 | }); 21 | }); 22 | --------------------------------------------------------------------------------