├── llamapay ├── py.typed ├── exceptions.py ├── __init__.py ├── constants.py ├── manifest.json └── llamapay.py ├── setup.cfg ├── ape-config.yaml ├── .github ├── workflows │ ├── draft.yaml │ ├── title.yaml │ ├── commitlint.yaml │ ├── publish.yaml │ └── test.yaml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── feature.md │ ├── bug.md │ └── work-item.md └── release-drafter.yml ├── .pre-commit-config.yaml ├── tests ├── test_factory.py ├── conftest.py ├── test_pool.py └── test_stream.py ├── pyproject.toml ├── CONTRIBUTING.md ├── .gitignore ├── setup.py ├── README.md └── LICENSE /llamapay/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /llamapay/exceptions.py: -------------------------------------------------------------------------------- 1 | class PoolNotDeployed(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | exclude = 4 | venv* 5 | .eggs 6 | docs 7 | build 8 | -------------------------------------------------------------------------------- /llamapay/__init__.py: -------------------------------------------------------------------------------- 1 | from .llamapay import Factory, Pool, Stream 2 | 3 | __all__ = [ 4 | "Factory", 5 | "Pool", 6 | "Stream", 7 | ] 8 | -------------------------------------------------------------------------------- /ape-config.yaml: -------------------------------------------------------------------------------- 1 | default_ecosystem: ethereum 2 | ethereum: 3 | default_network: mainnet-fork 4 | mainnet_fork: 5 | default_provider: foundry 6 | foundry: 7 | port: 7545 8 | -------------------------------------------------------------------------------- /.github/workflows/draft.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update-draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Drafts your next Release notes as Pull Requests are merged into "main" 13 | - uses: release-drafter/release-drafter@v5 14 | with: 15 | disable-autolabeler: true 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What I did 2 | 3 | 4 | fixes: # 5 | 6 | ### How I did it 7 | 8 | ### How to verify it 9 | 10 | ### Checklist 11 | 12 | - [ ] Passes all linting checks (pre-commit and CI jobs) 13 | - [ ] New test cases have been added and are passing 14 | - [ ] Documentation has been updated 15 | - [ ] PR title follows [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) standard (will be automatically included in the changelog) 16 | -------------------------------------------------------------------------------- /.github/workflows/title.yaml: -------------------------------------------------------------------------------- 1 | name: PR Title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | check: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Setup Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.8 21 | 22 | - name: Install Dependencies 23 | run: pip install commitizen 24 | 25 | - name: Check PR Title 26 | env: 27 | TITLE: ${{ github.event.pull_request.title }} 28 | run: cz check --message "$TITLE" 29 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | name: Commit Message 4 | 5 | # NOTE: Skip check on PR so as not to confuse contributors 6 | # NOTE: Also install a PR title checker so we don't mess up merges 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.8 20 | 21 | - name: Install Dependencies 22 | run: pip install .[dev] 23 | 24 | - name: Check commit history 25 | run: cz check --rev-range $(git rev-list --all --reverse | head -1)..HEAD 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: check-yaml 6 | 7 | - repo: https://github.com/pre-commit/mirrors-isort 8 | rev: v5.9.3 9 | hooks: 10 | - id: isort 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 22.3.0 14 | hooks: 15 | - id: black 16 | name: black 17 | 18 | - repo: https://gitlab.com/pycqa/flake8 19 | rev: 3.9.2 20 | hooks: 21 | - id: flake8 22 | 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v0.910-1 25 | hooks: 26 | - id: mypy 27 | additional_dependencies: [types-PyYAML, types-requests] 28 | 29 | 30 | default_language_version: 31 | python: python3.8 32 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: 3.8 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -e .[release] 24 | 25 | - name: Build 26 | run: python setup.py sdist bdist_wheel 27 | 28 | - name: Publish 29 | env: 30 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 31 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 32 | run: twine upload dist/* --verbose 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a new feature, or an improvement to existing functionality. 4 | labels: 'enhancement' 5 | --- 6 | 7 | ### Overview 8 | 9 | Provide a simple overview of what you wish to see added. Please include: 10 | 11 | * What you are trying to do 12 | * Why Ape's current functionality is inadequate to address your goal 13 | 14 | ### Specification 15 | 16 | Describe the syntax and semantics of how you would like to see this feature implemented. The more detailed the better! 17 | 18 | Remember, your feature is much more likely to be included if it does not involve any breaking changes. 19 | 20 | ### Dependencies 21 | 22 | Include links to any open issues that must be resolved before this feature can be implemented. 23 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | 4 | categories: 5 | - title: 'Features' 6 | labels: 7 | - 'feat' 8 | - title: 'Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - title: 'Other updates' 12 | labels: 13 | - 'refactor' 14 | - 'chore' 15 | - 'docs' 16 | 17 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 18 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 19 | 20 | version-resolver: 21 | major: 22 | labels: 23 | - 'major' 24 | minor: 25 | labels: 26 | - 'minor' 27 | patch: 28 | labels: 29 | - 'patch' 30 | default: patch 31 | 32 | template: | 33 | ## Changes 34 | 35 | $CHANGES 36 | 37 | Special thanks to: $CONTRIBUTORS 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an error that you've encountered. 4 | labels: 'bug' 5 | --- 6 | ### Environment information 7 | 8 | * `ape` and plugin versions: 9 | 10 | ``` 11 | $ ape --version 12 | # ...copy and paste result of above command here... 13 | 14 | $ ape plugins list 15 | # ...copy and paste result of above command here... 16 | ``` 17 | 18 | * Python Version: x.x.x 19 | * OS: osx/linux/win 20 | 21 | ### What went wrong? 22 | 23 | Please include information like: 24 | 25 | * what command you ran 26 | * the code that caused the failure (see [this link](https://help.github.com/articles/basic-writing-and-formatting-syntax/) for help with formatting code) 27 | * full output of the error you received 28 | 29 | ### How can it be fixed? 30 | 31 | Fill this in if you have ideas on how the bug could be fixed. 32 | -------------------------------------------------------------------------------- /tests/test_factory.py: -------------------------------------------------------------------------------- 1 | import ape 2 | import pytest 3 | from ape.exceptions import ConversionError 4 | from ape_tokens import tokens 5 | 6 | from llamapay.exceptions import PoolNotDeployed 7 | 8 | 9 | def test_factory_get_pool(factory): 10 | assert factory.get_pool(tokens["DAI"].address) == factory.get_pool("DAI") 11 | 12 | 13 | def test_factory_get_pool_not_address(factory): 14 | with pytest.raises(ConversionError): 15 | factory.get_pool("not_an_address") 16 | 17 | 18 | def test_factory_get_pool_not_exists(factory): 19 | with pytest.raises(PoolNotDeployed): 20 | factory.get_pool("UST") # too soon 21 | 22 | 23 | def test_create_pool(factory, bird): 24 | pool = factory.create_pool("YFI", sender=bird) 25 | assert pool.token == tokens["YFI"] 26 | 27 | 28 | def test_create_pool_non_token(factory, bird, bee): 29 | non_token = str(bee) 30 | with ape.reverts(): 31 | factory.create_pool(non_token, sender=bird) 32 | 33 | 34 | def test_pools(factory): 35 | assert len(factory.pools) >= 1 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=51.1.1", "wheel", "setuptools_scm[toml]>=5.0"] 3 | 4 | [tool.mypy] 5 | exclude = "build/" 6 | 7 | [tool.setuptools_scm] 8 | write_to = "llamapay/version.py" 9 | 10 | # NOTE: you have to use single-quoted strings in TOML for regular expressions. 11 | # It's the equivalent of r-strings in Python. Multiline strings are treated as 12 | # verbose regular expressions by Black. Use [ ] to denote a significant space 13 | # character. 14 | 15 | [tool.black] 16 | line-length = 100 17 | target-version = ['py37', 'py38', 'py39'] 18 | include = '\.pyi?$' 19 | 20 | [tool.pytest.ini_options] 21 | addopts = """ 22 | --cov-branch 23 | --cov-report term 24 | --cov-report html 25 | --cov-report xml 26 | --cov=llamapay 27 | """ 28 | python_files = "test_*.py" 29 | testpaths = "tests" 30 | markers = "fuzzing: Run Hypothesis fuzz test suite" 31 | 32 | [tool.isort] 33 | line_length = 100 34 | force_grid_wrap = 0 35 | include_trailing_comma = true 36 | multi_line_output = 3 37 | use_parentheses = true 38 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ape import chain 3 | from eth_abi import encode_abi 4 | from eth_utils import keccak 5 | 6 | from llamapay import Factory, Stream 7 | 8 | 9 | def set_balance(token, account, value, storage_index): 10 | # obtain a token balance with this one simple trick 11 | slot = keccak( 12 | encode_abi( 13 | ["address", "uint"], 14 | [account, storage_index], 15 | ) 16 | ) 17 | chain.provider._make_request( 18 | "anvil_setStorageAt", 19 | [token, slot.hex(), hex(value)], 20 | ) 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def bird(accounts): 25 | return accounts[0] 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def bee(accounts): 30 | return accounts[1] 31 | 32 | 33 | @pytest.fixture(scope="session") 34 | def factory(): 35 | return Factory() 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def pool(factory): 40 | return factory.get_pool("DAI") 41 | 42 | 43 | @pytest.fixture(scope="session") 44 | def stream(pool, bird, bee): 45 | # stream 0.01 DAI per second from bird to bee 46 | return Stream(source=str(bird), target=str(bee), rate=10**18, pool=pool) 47 | 48 | 49 | @pytest.fixture(scope="session") 50 | def token(pool, bird): 51 | set_balance(str(pool.token), str(bird), 10**24, storage_index=2) 52 | return pool.token 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/work-item.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Work item 3 | about: New work item for Ape team 4 | labels: 'backlog' 5 | 6 | --- 7 | 8 | ### Elevator pitch: 9 | 10 | 11 | ### Value: 12 | 17 | 18 | ### Dependencies: 19 | 20 | 21 | ### Design approach: 22 | 26 | 27 | ### Task list: 28 | 29 | * [ ] Tasks go here 30 | 31 | ### Estimated completion date: 32 | 33 | 34 | ### Design review: 35 | 36 | Do not signoff unless: 37 | - 1) agreed the tasks and design approach will achieve acceptance, and 38 | - 2) the work can be completed by one person within the SLA. 39 | Design reviewers should consider simpler approaches to achieve goals. 40 | 41 | (Please leave a comment to sign off) 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | To get started with working on the codebase, use the following steps prepare your local environment: 4 | 5 | ```bash 6 | # clone the github repo and navigate into the folder 7 | git clone https://github.com/banteg/ape-llamapay.git 8 | cd ape-llamapay 9 | 10 | # create and load a virtual environment 11 | python3 -m venv venv 12 | source venv/bin/activate 13 | 14 | # install the developer dependencies (-e is interactive mode) 15 | pip install -e .[dev] 16 | ``` 17 | 18 | ## Pre-Commit Hooks 19 | 20 | We use [`pre-commit`](https://pre-commit.com/) hooks to simplify linting and ensure consistent formatting among contributors. 21 | Use of `pre-commit` is not a requirement, but is highly recommended. 22 | 23 | Install `pre-commit` locally from the root folder: 24 | 25 | ```bash 26 | pip install pre-commit 27 | pre-commit install 28 | ``` 29 | 30 | Committing will now automatically run the local hooks and ensure that your commit passes all lint checks. 31 | 32 | ## Pull Requests 33 | 34 | Pull requests are welcomed! Please adhere to the following: 35 | 36 | - Ensure your pull request passes our linting checks 37 | - Include test cases for any new functionality 38 | - Include any relevant documentation updates 39 | 40 | It's a good idea to make pull requests early on. 41 | A pull request represents the start of a discussion, and doesn't necessarily need to be the final, finished submission. 42 | 43 | If you are opening a work-in-progress pull request to verify that it passes CI tests, please consider 44 | [marking it as a draft](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests#draft-pull-requests). 45 | 46 | Join the Ethereum Python [Discord](https://discord.gg/PcEJ54yX) if you have any questions. 47 | -------------------------------------------------------------------------------- /tests/test_pool.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | 5 | 6 | def test_pool_get_balance(pool): 7 | assert pool.get_balance("0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52") > 0 8 | 9 | 10 | @pytest.mark.parametrize("amount", [10**21, "1000 DAI", Decimal("1000")]) 11 | def test_pool_approve(pool, token, bird, amount): 12 | wei_amount = 10**21 13 | pool.approve(amount, sender=bird) 14 | assert token.allowance(bird, pool.address) == wei_amount 15 | 16 | 17 | def test_pool_approve_inf(pool, token, bird): 18 | pool.approve(sender=bird) 19 | max_uint = 2**256 - 1 20 | assert token.allowance(bird, pool.address) == max_uint 21 | 22 | 23 | @pytest.mark.parametrize("amount", [10**21, "1000 DAI", Decimal("1000")]) 24 | def test_pool_deposit(pool, token, bird, amount): 25 | token_amount = Decimal("1000") 26 | # should approve automatically 27 | pool.deposit(amount, sender=bird) 28 | assert pool.get_balance(bird) == token_amount 29 | assert token.allowance(bird, pool.address) == 0 30 | 31 | 32 | @pytest.mark.parametrize("amount", [10**21, "1000 DAI", Decimal("1000")]) 33 | def test_pool_withdraw(pool, bird, token, amount): 34 | amount = pool._convert_amount(amount) 35 | pool.deposit(amount, sender=bird) 36 | pool.withdraw(amount, sender=bird) 37 | assert pool.get_balance(str(bird)) == 0 38 | assert token.balanceOf(str(bird)) == 10**24 39 | 40 | 41 | @pytest.mark.parametrize("amount", [10**21, "1000 DAI", Decimal("1000")]) 42 | def test_pool_withdraw_all(pool, bird, token, amount): 43 | pool.deposit(amount, sender=bird) 44 | pool.withdraw(sender=bird) 45 | assert token.balanceOf(str(bird)) == 10**24 46 | print(token.balanceOf(str(bird))) 47 | 48 | 49 | def test_pool_create_stream(pool, bird, bee): 50 | stream = pool.make_stream(bird, bee, "1000 DAI/month") 51 | receipt = stream.create(sender=bird) 52 | log = next(receipt.decode_logs(pool.contract.StreamCreated)) 53 | assert stream.rate == log.amountPerSec 54 | 55 | 56 | def test_factory_create_stream(factory, bird, bee): 57 | stream = factory.create_stream(bee, "1000 DAI/month", sender=bird) 58 | print(stream) 59 | -------------------------------------------------------------------------------- /tests/test_stream.py: -------------------------------------------------------------------------------- 1 | def test_stream_id(pool, stream): 2 | stream_id = pool.contract.getStreamId(stream.source, stream.target, stream.rate) 3 | assert stream.id == stream_id 4 | 5 | 6 | def test_stream_create(pool, stream, bird): 7 | receipt = stream.create(sender=bird) 8 | log = next(receipt.decode_logs(pool.contract.StreamCreated)) 9 | assert log.amountPerSec == stream.rate 10 | 11 | 12 | def test_stream_withdrawable(pool, bird, bee, chain, token, stream): 13 | pool.deposit("1000 DAI", sender=bird) 14 | stream.create(sender=bird) 15 | chain.mine() 16 | assert stream.balance > 0 17 | 18 | result = pool.contract.withdrawable(stream.source, stream.target, stream.rate) 19 | assert stream.balance * pool.scale == result.withdrawableAmount 20 | 21 | 22 | def test_stream_send(pool, stream, bird, bee, token, chain): 23 | pool.deposit("1000 DAI", sender=bird) 24 | stream.create(sender=bird) 25 | chain.mine() 26 | assert stream.balance > 0 27 | 28 | receipt = stream.withdraw(sender=bird) 29 | log = next(receipt.decode_logs(pool.token.Transfer)) 30 | assert log.amount > 0 31 | 32 | 33 | def test_stream_cancel(pool, stream, bird, bee): 34 | stream.create(sender=bird) 35 | receipt = stream.cancel(sender=bird) 36 | log = next(receipt.decode_logs(pool.contract.StreamCancelled)) 37 | assert log.streamId == stream.id 38 | 39 | 40 | def test_stream_pause(pool, stream, bird, bee): 41 | stream.create(sender=bird) 42 | receipt = stream.pause(sender=bird) 43 | log = next(receipt.decode_logs(pool.contract.StreamPaused)) 44 | assert log.streamId == stream.id 45 | 46 | 47 | def test_stream_replace(pool, bird, bee, stream): 48 | stream.create(sender=bird) 49 | new_stream = pool.make_stream(str(bird), str(bee), stream.rate * 2) 50 | receipt = stream.replace(new_stream, sender=bird) 51 | log = next(receipt.decode_logs(pool.contract.StreamModified)) 52 | assert log.oldAmountPerSec * 2 == log.amountPerSec 53 | 54 | 55 | def test_stream_modify(pool, bird, bee, stream): 56 | stream.create(sender=bird) 57 | receipt = stream.modify(rate=stream.rate * 2, sender=bird) 58 | log = next(receipt.decode_logs(pool.contract.StreamModified)) 59 | assert log.oldAmountPerSec * 2 == log.amountPerSec 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | on: ["push", "pull_request"] 2 | 3 | name: Test 4 | 5 | jobs: 6 | linting: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Setup Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.8 16 | 17 | - name: Install Dependencies 18 | run: pip install .[lint] 19 | 20 | - name: Run Black 21 | run: black --check . 22 | 23 | - name: Run flake8 24 | run: flake8 . 25 | 26 | - name: Run isort 27 | run: isort --check-only . 28 | 29 | type-check: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | 35 | - name: Setup Python 36 | uses: actions/setup-python@v2 37 | with: 38 | python-version: 3.8 39 | 40 | - name: Install Dependencies 41 | run: pip install .[lint,test] # Might need test deps 42 | 43 | - name: Run MyPy 44 | run: mypy . 45 | 46 | functional: 47 | runs-on: ${{ matrix.os }} 48 | 49 | strategy: 50 | matrix: 51 | os: [ubuntu-latest, macos-latest] # eventually add `windows-latest` 52 | python-version: [3.7, 3.8, 3.9] 53 | 54 | steps: 55 | - uses: actions/checkout@v2 56 | 57 | - name: Setup Python 58 | uses: actions/setup-python@v2 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | 62 | - name: Install Dependencies 63 | run: pip install .[test] 64 | 65 | - name: Run Tests 66 | run: pytest -m "not fuzzing" -n 0 -s --cov 67 | 68 | # NOTE: uncomment this block after you've marked tests with @pytest.mark.fuzzing 69 | # fuzzing: 70 | # runs-on: ubuntu-latest 71 | # 72 | # strategy: 73 | # fail-fast: true 74 | # 75 | # steps: 76 | # - uses: actions/checkout@v2 77 | # 78 | # - name: Setup Python 79 | # uses: actions/setup-python@v2 80 | # with: 81 | # python-version: 3.8 82 | # 83 | # - name: Install Dependencies 84 | # run: pip install .[test] 85 | # 86 | # - name: Run Tests 87 | # run: pytest -m "fuzzing" --no-cov -s 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | .dmypy.json 115 | dmypy.json 116 | 117 | # setuptools-scm 118 | version.py 119 | 120 | # Ape stuff 121 | .build/ 122 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup # type: ignore 3 | 4 | extras_require = { 5 | "test": [ # `test` GitHub Action jobs uses this 6 | "pytest>=6.0,<7.0", # Core testing package 7 | "pytest-xdist", # multi-process runner 8 | "pytest-cov", # Coverage analyzer plugin 9 | "hypothesis>=6.2.0,<7.0", # Strategy-based fuzzer 10 | ], 11 | "lint": [ 12 | "black>=21.3.0", # auto-formatter and linter 13 | "mypy>=0.910,<1.0", # Static type analyzer 14 | "flake8>=3.8.3,<4.0", # Style linter 15 | "isort>=5.9.3,<6.0", # Import sorting linter 16 | ], 17 | "release": [ # `release` GitHub Action job uses this 18 | "setuptools", # Installation tool 19 | "wheel", # Packaging tool 20 | "twine", # Package upload tool 21 | ], 22 | "dev": [ 23 | "commitizen", # Manage commits and publishing releases 24 | "pre-commit", # Ensure that linters are run prior to commiting 25 | "pytest-watch", # `ptw` test watcher/runner 26 | "IPython", # Console for interacting 27 | "ipdb", # Debugger (Must use `export PYTHONBREAKPOINT=ipdb.set_trace`) 28 | ], 29 | } 30 | 31 | # NOTE: `pip install -e .[dev]` to install package 32 | extras_require["dev"] = ( 33 | extras_require["test"] 34 | + extras_require["lint"] 35 | + extras_require["release"] 36 | + extras_require["dev"] 37 | ) 38 | 39 | with open("./README.md") as readme: 40 | long_description = readme.read() 41 | 42 | 43 | setup( 44 | name="llamapay", 45 | use_scm_version=True, 46 | setup_requires=["setuptools_scm"], 47 | description="""llamapay: ape sdk for llamapay""", 48 | long_description=long_description, 49 | long_description_content_type="text/markdown", 50 | author="banteg", 51 | author_email="banteg@pm.me", 52 | url="https://github.com/banteg/ape-llamapay", 53 | include_package_data=True, 54 | install_requires=[ 55 | "importlib-metadata ; python_version<'3.8'", 56 | "ape-tokens>=0.2.0", 57 | "ape-ens>=0.2.0", 58 | "eth-ape>=0.2.7.dev", 59 | ], # NOTE: Add 3rd party libraries here 60 | python_requires=">=3.7,<4", 61 | extras_require=extras_require, 62 | license="Apache-2.0", 63 | zip_safe=False, 64 | keywords="ethereum", 65 | packages=["llamapay"], 66 | package_data={"llamapay": ["py.typed"]}, 67 | classifiers=[ 68 | "Development Status :: 4 - Beta", 69 | "Intended Audience :: Developers", 70 | "License :: OSI Approved :: Apache Software License", 71 | "Natural Language :: English", 72 | "Operating System :: MacOS", 73 | "Operating System :: POSIX", 74 | "Programming Language :: Python :: 3", 75 | "Programming Language :: Python :: 3.7", 76 | "Programming Language :: Python :: 3.8", 77 | "Programming Language :: Python :: 3.9", 78 | ], 79 | ) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ape-llamapay 2 | 3 | Manage [LlamaPay](https://llamapay.io) payment streams effortlessly from [Ape Framework](https://www.apeworx.io). 4 | 5 | ## Quick Usage 6 | 7 | You can use this SDK in `ape console` or in ape scripts. A short example: 8 | 9 | ```python 10 | from llamapay import Factory 11 | 12 | factory = Factory() 13 | factory.create_stream('banteg.eth', '1000 DAI/month', sender=accounts.load('dev')) 14 | ``` 15 | 16 | If deployment exists on the connected network, it will be automatically picked up. 17 | 18 | A `Factory` is a registry and deployer of new pools. Each `Pool` manages all streams of a specific token. A `Stream` stores metadata like `source`, `target`, `rate` and allows operations on streams. 19 | 20 | You can find pools by token address or symbol, courtesy of `ape-tokens`: 21 | ```python 22 | factory.pools 23 | factory.create_pool('YFI') 24 | factory.get_pool('DAI') 25 | ``` 26 | 27 | You can find streams from event logs and filter them by `source` or `target`, including their ENS names, courtesy of `ape-ens`: 28 | ```python 29 | pool.all_streams 30 | pool.find_streams(source='ychad.eth') 31 | pool.find_streams(target='wentokyo.eth') 32 | ``` 33 | 34 | To fund your streams you will need to deposit funds into a pool: 35 | ```python 36 | pool.get_balance('ychad.eth') 37 | # infinite approve (optional) 38 | pool.approve(sender=dev) 39 | # auto approves the requested amount 40 | pool.deposit('1000 DAI', sender=dev) 41 | # withdraw all 42 | pool.withdraw(sender=dev) 43 | # withdraw some 44 | pool.withdraw(Decimal('500'), sender=dev) 45 | ``` 46 | 47 | Token amounts can be specified as `int` for wei, `Decimal` for tokens, or `str` to be converted by `ape-tokens` based on their decimals. 48 | 49 | It is easiest to prepare a stream from the `Pool` instance: 50 | ```python 51 | stream = pool.make_stream(source=dev, target=crush, rate='1000 DAI/month') 52 | ``` 53 | 54 | You can specify the rate as `int` for the internal 1e20 tokens per second representation or use a simple `str` format of `amount symbol/duration` like `1 YFI/week` or `200,000 UNI/year`. 55 | 56 | Now that you have a `Stream` prepared, you can create it: 57 | ```python 58 | stream.create(sender=dev) 59 | stream.pause(sender=dev) 60 | stream.cancel(sender=dev) 61 | stream.replace(new_stream, sender=dev) 62 | stream.modify(target=new_crush, sender=dev) 63 | stream.modify(rate=stream.rate * 2, sender=dev) 64 | # check your withdrawable balance 65 | stream.balance 66 | # push tokens to recipient or withdraw them if you are one (these methods are the same) 67 | stream.send(sender=dev) 68 | stream.withdraw(sender=dev) 69 | ``` 70 | 71 | ## Dependencies 72 | 73 | * [python3](https://www.python.org/downloads) version 3.7 or greater, python3-dev 74 | 75 | ## Installation 76 | 77 | ### via `pip` 78 | 79 | You can install the latest release via [`pip`](https://pypi.org/project/pip/): 80 | 81 | ```bash 82 | pip install llamapay 83 | ``` 84 | 85 | ### via `setuptools` 86 | 87 | You can clone the repository and use [`setuptools`](https://github.com/pypa/setuptools) for the most up-to-date version: 88 | 89 | ```bash 90 | git clone https://github.com/banteg/ape-llamapay.git 91 | cd ape-llamapay 92 | python3 setup.py install 93 | ``` 94 | 95 | ## Development 96 | 97 | This project is in development and should be considered a beta. 98 | Things might not be in their final state and breaking changes may occur. 99 | Comments, questions, criticisms and pull requests are welcomed. 100 | 101 | ## License 102 | 103 | This project is licensed under the [Apache 2.0](LICENSE). 104 | -------------------------------------------------------------------------------- /llamapay/constants.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | from datetime import timedelta 3 | from typing import List 4 | 5 | from ethpm_types import PackageManifest 6 | from pydantic import BaseModel 7 | 8 | manifest = pkgutil.get_data(__package__, "manifest.json") 9 | CONTRACT_TYPES = PackageManifest.parse_raw(manifest).contract_types # type: ignore 10 | 11 | 12 | class FactoryDeployment(BaseModel): 13 | ecosystem: str 14 | network: str 15 | address: str 16 | deploy_block: int 17 | 18 | class Config: 19 | allow_mutation = False 20 | 21 | 22 | class Deplyoments(BaseModel): 23 | __root__: List[FactoryDeployment] 24 | 25 | def get(self, ecosystem, network): 26 | return next( 27 | item 28 | for item in self.__root__ 29 | if item.ecosystem == ecosystem and item.network == network 30 | ) 31 | 32 | 33 | FACTORY_DEPLOYMENTS = Deplyoments( 34 | __root__=[ 35 | FactoryDeployment( 36 | ecosystem="ethereum", 37 | network="mainnet", 38 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 39 | deploy_block=14_676_643, 40 | ), 41 | FactoryDeployment( 42 | ecosystem="ethereum", 43 | network="rinkeby", 44 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 45 | deploy_block=10_582_060, 46 | ), 47 | FactoryDeployment( 48 | ecosystem="ethereum", 49 | network="kovan", 50 | address="0xD43bB75Cc924e8475dFF2604b962f39089e4f842", 51 | deploy_block=31_267_493, 52 | ), 53 | FactoryDeployment( 54 | ecosystem="optimism", 55 | network="mainnet", 56 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 57 | # transaction index, TODO verify if it works as intended 58 | deploy_block=6_699_902, 59 | ), 60 | FactoryDeployment( 61 | ecosystem="arbitrum", 62 | network="mainnet", 63 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 64 | deploy_block=10_785_890, 65 | ), 66 | FactoryDeployment( 67 | ecosystem="avalanche", 68 | network="mainnet", 69 | address="0x7d507b4c2d7e54da5731f643506996da8525f4a3", 70 | deploy_block=13_948_155, 71 | ), 72 | FactoryDeployment( 73 | ecosystem="avalanche", 74 | network="fuji", 75 | address="0xc4705f96030D347F421Fbe01d9A19F18B26a7d30", 76 | deploy_block=9_057_940, 77 | ), 78 | FactoryDeployment( 79 | ecosystem="fantom", 80 | network="opera", 81 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 82 | deploy_block=37_130_440, 83 | ), 84 | FactoryDeployment( 85 | ecosystem="polygon", 86 | network="mainnet", 87 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 88 | deploy_block=27_671_043, 89 | ), 90 | FactoryDeployment( 91 | ecosystem="bsc", 92 | network="mainnet", 93 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 94 | deploy_block=17_356_891, 95 | ), 96 | FactoryDeployment( 97 | # provisional name, there is no gnosis chain / xdai plugin yet 98 | ecosystem="gnosis", 99 | network="mainnet", 100 | address="0xde1C04855c2828431ba637675B6929A684f84C7F", 101 | deploy_block=21_881_219, 102 | ), 103 | ] 104 | ) 105 | 106 | 107 | DURATION_TO_SECONDS = { 108 | period: int(timedelta(days=days).total_seconds()) 109 | for period, days in [ 110 | ("day", 1), 111 | ("week", 7), 112 | ("month", 30), 113 | ("year", 365.2425), 114 | ] 115 | } 116 | 117 | PRECISION = 10**20 118 | -------------------------------------------------------------------------------- /llamapay/manifest.json: -------------------------------------------------------------------------------- 1 | {"contractTypes":{"LlamaPay":{"abi":[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"PayerDeposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"PayerWithdraw","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"streamId","type":"bytes32"}],"name":"StreamCancelled","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"streamId","type":"bytes32"}],"name":"StreamCreated","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"streamId","type":"bytes32"},{"indexed":false,"internalType":"string","name":"reason","type":"string"}],"name":"StreamCreatedWithReason","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"oldTo","type":"address"},{"indexed":false,"internalType":"uint216","name":"oldAmountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"oldStreamId","type":"bytes32"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"newStreamId","type":"bytes32"}],"name":"StreamModified","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"streamId","type":"bytes32"}],"name":"StreamPaused","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"indexed":false,"internalType":"bytes32","name":"streamId","type":"bytes32"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Withdraw","type":"event"},{"inputs":[],"name":"DECIMALS_DIVISOR","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"balances","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes[]","name":"calls","type":"bytes[]"},{"internalType":"bool","name":"revertOnFail","type":"bool"}],"name":"batch","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"cancelStream","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"createStream","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"internalType":"string","name":"reason","type":"string"}],"name":"createStreamWithReason","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"deposit","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amountToDeposit","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"depositAndCreate","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amountToDeposit","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"},{"internalType":"string","name":"reason","type":"string"}],"name":"depositAndCreateWithReason","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"payerAddress","type":"address"}],"name":"getPayerBalance","outputs":[{"internalType":"int256","name":"","type":"int256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"getStreamId","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"oldTo","type":"address"},{"internalType":"uint216","name":"oldAmountPerSec","type":"uint216"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"modifyStream","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"pauseStream","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"payers","outputs":[{"internalType":"uint40","name":"lastPayerUpdate","type":"uint40"},{"internalType":"uint216","name":"totalPaidPerSec","type":"uint216"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"contract IERC20Permit","name":"token","type":"address"},{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"permitToken","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"name":"streamToStart","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"token","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"withdrawPayer","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"withdrawPayerAll","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint216","name":"amountPerSec","type":"uint216"}],"name":"withdrawable","outputs":[{"internalType":"uint256","name":"withdrawableAmount","type":"uint256"},{"internalType":"uint256","name":"lastUpdate","type":"uint256"},{"internalType":"uint256","name":"owed","type":"uint256"}],"stateMutability":"view","type":"function"}],"contractName":"LlamaPay","devdoc":{"kind":"dev","methods":{"batch(bytes[],bool)":{"params":{"calls":"An array of inputs for each call.","revertOnFail":"If True then reverts after a failed call and stops doing further calls."}}},"version":1},"userdoc":{"kind":"user","methods":{"batch(bytes[],bool)":{"notice":"Allows batched call to self (this contract)."},"permitToken(address,address,address,uint256,uint256,uint8,bytes32,bytes32)":{"notice":"Call wrapper that performs `ERC20.permit` on `token`. Lookup `IERC20.permit`."}},"version":1}},"LlamaPayFactory":{"abi":[{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"token","type":"address"},{"indexed":false,"internalType":"address","name":"llamaPay","type":"address"}],"name":"LlamaPayCreated","type":"event"},{"inputs":[{"internalType":"address","name":"_token","type":"address"}],"name":"createLlamaPayContract","outputs":[{"internalType":"address","name":"llamaPayContract","type":"address"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"getLlamaPayContractByIndex","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_token","type":"address"}],"name":"getLlamaPayContractByToken","outputs":[{"internalType":"address","name":"predictedAddress","type":"address"},{"internalType":"bool","name":"isDeployed","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getLlamaPayContractCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"parameter","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}],"contractName":"LlamaPayFactory","devdoc":{"kind":"dev","methods":{"createLlamaPayContract(address)":{"details":"Instances are created deterministically via CREATE2 and duplicate instances will cause a revert","params":{"_token":"The ERC20 token address for which a Llama Pay contract should be deployed"},"returns":{"llamaPayContract":"The address of the newly created Llama Pay contract"}},"getLlamaPayContractByToken(address)":{"params":{"_token":"An ERC20 token address"},"returns":{"isDeployed":"Boolean denoting whether the contract is currently deployed","predictedAddress":"The deterministic address where the llama pay contract will be deployed for `_token`"}}},"version":1},"userdoc":{"kind":"user","methods":{"createLlamaPayContract(address)":{"notice":"Create a new Llama Pay Streaming instance for `_token`"},"getLlamaPayContractByToken(address)":{"notice":"Query the address of the Llama Pay contract for `_token` and whether it is deployed"}},"version":1}}},"manifest":"ethpm/3"} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 ApeWorX Ltd. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /llamapay/llamapay.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from decimal import Decimal 3 | from functools import cached_property 4 | from typing import List, Optional, Union 5 | 6 | from ape.api import ReceiptAPI 7 | from ape.types import AddressType, ContractLog 8 | from ape.utils import ManagerAccessMixin 9 | from ape_tokens import tokens 10 | from ape_tokens.managers import ERC20 11 | from eth_abi.packed import encode_abi_packed 12 | from eth_utils import keccak 13 | 14 | from llamapay.constants import CONTRACT_TYPES, DURATION_TO_SECONDS, FACTORY_DEPLOYMENTS, PRECISION 15 | from llamapay.exceptions import PoolNotDeployed 16 | 17 | 18 | class Factory(ManagerAccessMixin): 19 | """ 20 | LlamaPay streams for each token are contained in pools. 21 | This factory helps discover and deploy new pools. 22 | """ 23 | 24 | def __init__(self): 25 | self.deployment = FACTORY_DEPLOYMENTS.get( 26 | ecosystem=self.provider.network.ecosystem.name, 27 | network=self.provider.network.name.replace("-fork", ""), 28 | ) 29 | self.contract = self.create_contract( 30 | self.deployment.address, CONTRACT_TYPES["LlamaPayFactory"] 31 | ) 32 | 33 | def get_pool(self, token: str) -> "Pool": 34 | """ 35 | Get pool by token address or symbol. 36 | """ 37 | token = self._resolve_token(token) 38 | address, is_deployed = self.contract.getLlamaPayContractByToken(token) 39 | if not is_deployed: 40 | raise PoolNotDeployed("deterministic address: %s" % address) 41 | 42 | return Pool(address, factory=self) 43 | 44 | def create_pool(self, token: str, **tx_args) -> "Pool": 45 | """ 46 | Create a pool for a token and return it. 47 | """ 48 | token = self._resolve_token(token) 49 | self.contract.createLlamaPayContract(token, **tx_args) 50 | 51 | return self.get_pool(token) 52 | 53 | def create_stream(self, target, rate, token=None, **tx_args) -> "Stream": 54 | """ 55 | >>> factory.create_stream('banteg.eth', '1000 DAI/month') 56 | """ 57 | if token is None: 58 | token = rate.split("/")[0].split()[1] 59 | try: 60 | pool = self.get_pool(token) 61 | except PoolNotDeployed: 62 | print(f"creating pool for {token}") 63 | self.create_pool(token, **tx_args) 64 | 65 | pool = self.get_pool(token) 66 | stream = pool.make_stream(tx_args["sender"], target, rate) 67 | stream.create(**tx_args) 68 | return stream 69 | 70 | @property 71 | def pools(self) -> List["Pool"]: 72 | """ 73 | Get all pools deployed by a factory. 74 | """ 75 | # TODO update to use multicall 76 | pool_count = self.contract.getLlamaPayContractCount() 77 | pools = [ 78 | Pool(self.contract.getLlamaPayContractByIndex(i), factory=self) 79 | for i in range(pool_count) 80 | ] 81 | return pools 82 | 83 | def _resolve_token(self, token: str) -> AddressType: 84 | """ 85 | Resolve token address by symbol, address or ENS. 86 | """ 87 | try: 88 | token = tokens[token].address 89 | except KeyError: 90 | pass 91 | 92 | return self.conversion_manager.convert(token, AddressType) 93 | 94 | 95 | class Pool(ManagerAccessMixin): 96 | """ 97 | A pool handles all streams for a specific token. 98 | """ 99 | 100 | def __init__(self, address: AddressType, factory: Factory): 101 | self.address = address 102 | self.factory = factory 103 | self.contract = self.create_contract( 104 | self.address, 105 | CONTRACT_TYPES["LlamaPay"], # type: ignore 106 | ) 107 | self.token = self.create_contract(self.contract.token(), ERC20) 108 | # cache 109 | self._logs: List[ContractLog] = [] 110 | self._last_logs_block = self.factory.deployment.deploy_block 111 | self._streams: List["Stream"] = [] 112 | 113 | @cached_property 114 | def symbol(self): 115 | return self.token.symbol() 116 | 117 | @cached_property 118 | def scale(self): 119 | return 10 ** self.token.decimals() 120 | 121 | @cached_property 122 | def internal_scale(self): 123 | return self.contract.DECIMALS_DIVISOR() 124 | 125 | def _refresh_logs(self): 126 | start = self._last_logs_block 127 | head = self.chain_manager.blocks.height 128 | if start >= head: 129 | return 130 | 131 | logs = list( 132 | self.provider.get_contract_logs( 133 | self.address, 134 | self.contract.contract_type.events, 135 | start_block=start, 136 | stop_block=head, 137 | block_page_size=10_000, 138 | ) 139 | ) 140 | self._last_logs_block = head + 1 141 | self._logs.extend(logs) 142 | 143 | for log in logs: 144 | if log.name in ["StreamCreated", "StreamCreatedWithReason", "StreamModified"]: 145 | self._streams.append( 146 | Stream( 147 | source=log.event_arguments["from"], 148 | target=log.to, 149 | rate=log.amountPerSec, 150 | pool=self, 151 | ) 152 | ) 153 | 154 | @property 155 | def all_streams(self) -> List["Stream"]: 156 | self._refresh_logs() 157 | return self._streams[:] 158 | 159 | def find_streams( 160 | self, 161 | *, 162 | source: Optional[AddressType] = None, 163 | target: Optional[AddressType] = None, 164 | ) -> List["Stream"]: 165 | # handle ens 166 | if source: 167 | source = self.conversion_manager.convert(source, AddressType) 168 | if target: 169 | target = self.conversion_manager.convert(target, AddressType) 170 | # source & target 171 | if source and target: 172 | return [s for s in self.all_streams if s.source == source and s.target == target] 173 | elif source: 174 | return [s for s in self.all_streams if s.source == source] 175 | elif target: 176 | return [s for s in self.all_streams if s.target == target] 177 | else: 178 | raise ValueError("must specify source or target") 179 | 180 | def get_balance(self, source: AddressType) -> Decimal: 181 | return Decimal(self.contract.getPayerBalance(source)) / self.scale 182 | 183 | def approve(self, amount=None, **tx_args) -> ReceiptAPI: 184 | """ 185 | Approve token to be deposited into a pool. 186 | 187 | Arguments: 188 | amount: str, decimal or wei amount in tokens [default: infinite] 189 | """ 190 | amount = self._convert_amount(amount) 191 | return self.token.approve(self.address, amount, **tx_args) 192 | 193 | def deposit(self, amount, **tx_args) -> ReceiptAPI: 194 | """ 195 | Deposit funding balance into a pool. 196 | 197 | Arguments: 198 | amount: str, decimal or wei amount in tokens 199 | """ 200 | amount = self._convert_amount(amount) 201 | if self.token.allowance(tx_args["sender"], self.address) < amount: 202 | self.approve(amount, **tx_args) 203 | 204 | print(amount) 205 | return self.contract.deposit(amount, **tx_args) 206 | 207 | def withdraw(self, amount=None, **tx_args) -> ReceiptAPI: 208 | """ 209 | Withdraw funding balance from a pool. 210 | 211 | Arguments: 212 | amount: decimal amount in tokens [default: withdraw all] 213 | """ 214 | if amount: 215 | amount = self._convert_amount(amount) 216 | # withdrawPayer arg is scaled to 1e20, but we work with a token value 217 | amount *= self.internal_scale 218 | return self.contract.withdrawPayer(amount, **tx_args) 219 | else: 220 | return self.contract.withdrawPayerAll(**tx_args) 221 | 222 | def make_stream(self, source, target, rate) -> "Stream": 223 | """ 224 | Prepare a stream and calculate the rate. 225 | """ 226 | source = self.conversion_manager.convert(source, AddressType) 227 | target = self.conversion_manager.convert(target, AddressType) 228 | rate = convert_rate(rate) 229 | return Stream(source, target, rate, self) 230 | 231 | def _convert_amount(self, amount: Union[None, int, Decimal, str]) -> int: 232 | """ 233 | None -> max_uint 234 | int -> wei 235 | Decimal -> scaled to token decimals 236 | str -> converted by ape-tokens 237 | """ 238 | if amount is None: 239 | return 2**256 - 1 240 | if isinstance(amount, int): 241 | return amount 242 | if isinstance(amount, Decimal): 243 | return int(amount * self.scale) 244 | if isinstance(amount, str): 245 | return self.conversion_manager.convert(amount, int) 246 | 247 | raise TypeError("invalid amount") 248 | 249 | def __repr__(self): 250 | return f"" 251 | 252 | def __eq__(self, other) -> bool: 253 | assert isinstance(other, Pool) 254 | return self.address == other.address 255 | 256 | 257 | @dataclass 258 | class Stream(ManagerAccessMixin): 259 | """ 260 | Represents a payment stream. 261 | """ 262 | 263 | source: str 264 | target: str 265 | rate: int # rate in tokens per second, scaled to 1e20, doesn't depend of token decimals 266 | pool: Pool 267 | 268 | def __post_init__(self): 269 | self.source = self.conversion_manager.convert(self.source, AddressType) 270 | self.target = self.conversion_manager.convert(self.target, AddressType) 271 | self.rate = convert_rate(self.rate) 272 | 273 | @property 274 | def id(self) -> bytes: 275 | return keccak( 276 | encode_abi_packed( 277 | ["address", "address", "uint216"], 278 | [self.source, self.target, self.rate], 279 | ) 280 | ) 281 | 282 | def create(self, **tx_args): 283 | assert tx_args["sender"] == self.source, f"sender must be {self.source}" 284 | return self.pool.contract.createStream(self.target, self.rate, **tx_args) 285 | 286 | def pause(self, **tx_args): 287 | assert tx_args["sender"] == self.source, f"sender must be {self.source}" 288 | return self.pool.contract.pauseStream(self.target, self.rate, **tx_args) 289 | 290 | def cancel(self, **tx_args): 291 | assert tx_args["sender"] == self.source, f"sender must be {self.source}" 292 | return self.pool.contract.cancelStream(self.target, self.rate, **tx_args) 293 | 294 | def replace(self, stream: "Stream", **tx_args): 295 | assert tx_args["sender"] == self.source, f"sender must be {self.source}" 296 | return self.pool.contract.modifyStream( 297 | self.target, self.rate, stream.target, stream.rate, **tx_args 298 | ) 299 | 300 | def modify(self, *, target=None, rate=None, **tx_args): 301 | # a worse version of replace because you don't get the stream instance 302 | assert tx_args["sender"] == self.source, f"sender must be {self.source}" 303 | stream = Stream(self.source, target or self.target, rate or self.rate, self.pool) 304 | return self.replace(stream, **tx_args) 305 | 306 | def send(self, **tx_args): 307 | """ 308 | Push the pending withdrawal amount to target. Can be called by anyone. 309 | """ 310 | return self.pool.contract.withdraw(self.source, self.target, self.rate, **tx_args) 311 | 312 | withdraw = send 313 | 314 | @property 315 | def balance(self): 316 | """ 317 | Withdrawable balance of a stream. 318 | """ 319 | result = self.pool.contract.withdrawable(self.source, self.target, self.rate) 320 | return Decimal(result.withdrawableAmount) / self.pool.scale 321 | 322 | 323 | def convert_rate(rate): 324 | if isinstance(rate, int): 325 | return rate 326 | if isinstance(rate, str): 327 | amount, period = rate.split("/") 328 | assert period in DURATION_TO_SECONDS, "invalid period" 329 | try: 330 | amount, _ = amount.split(maxsplit=2) 331 | except ValueError: 332 | amount, _ = amount, None 333 | 334 | amount = Decimal(amount.replace(",", "_")) 335 | return int(amount * PRECISION / DURATION_TO_SECONDS[period]) 336 | 337 | raise ValueError("invalid rate") 338 | --------------------------------------------------------------------------------