diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4534de1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,61 @@ +name: publish + +on: + push: + tags: + - '*' + +jobs: + + test: + uses: ./.github/workflows/test.yml + secrets: inherit + + build: + name: build framelib dist + needs: test + + runs-on: ubuntu-latest + + steps: + - name: check out git repository + uses: actions/checkout@v4 + + - name: setup python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: install python dependencies + run: pip install build + + - name: build binary wheel and source tarball + run: python3 -m build + + - name: store the dist package + uses: actions/upload-artifact@v4 + with: + name: distribution + path: dist/ + + + publish: + name: publish framelib to pypi + needs: build + + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/framelib + permissions: + id-token: write + + steps: + - name: get dist package + uses: actions/download-artifact@v4 + with: + name: distribution + path: dist/ + + - name: publish package to pypi + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..baf7c65 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: test + +on: + push: + pull_request: + workflow_call: + +jobs: + + test: + name: run framelib unit tests + runs-on: ubuntu-latest + + steps: + - name: check out git repository + uses: actions/checkout@v4 + + - name: setup conda env + uses: conda-incubator/setup-miniconda@v2 + with: + activate-environment: framelib + environment-file: environment.yml + auto-activate-base: false + + - name: pytest + shell: bash -l {0} + env: + NEYNAR_KEY: ${{ secrets.NEYNAR_KEY }} + run: pytest -s -v ./test diff --git a/README.md b/README.md index 23aefeb..7d573fc 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ lightweight library for building farcaster frames using python and flask - easily render frames that conform to the farcaster specification -- configurable frame design -- parse frame action messages -- verify the frame action signatures using neynar +- parse and verify frame action messages using neynar or hubs - query user profile info from warpcast - on-chain frame transactions +- eip-712 signatures +- mint tokens ## quickstart @@ -27,7 +27,7 @@ app = Flask(__name__) @app.route('/') def home(): return frame( - image='https://opengraph.githubassets.com/0x/devinaconley/python-frames', + image='https://framelib.s3.us-east-1.amazonaws.com/framelib_logo.png', button1='next', post_url=url_for('second_page', _external=True), ) @@ -46,9 +46,10 @@ see [rock paper scissors](https://github.com/devinaconley/rock-paper-scissors) ## roadmap upcoming features and improvements -- mint actions -- eip 712 signatures +- ~~mint actions~~ +- ~~eip 712 signatures~~ - generated library documentation -- dynamic image rendering tools +- ~~dynamic image rendering tools~~ - compatibility with other web frameworks - state signing +- **frames v2 support** diff --git a/examples/playground/.gitignore b/examples/playground/.gitignore new file mode 100644 index 0000000..ddf0756 --- /dev/null +++ b/examples/playground/.gitignore @@ -0,0 +1,166 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# custom +package.json +package-lock.json +node_modules/ +.vercel diff --git a/examples/playground/README.md b/examples/playground/README.md new file mode 100644 index 0000000..7cc05c7 --- /dev/null +++ b/examples/playground/README.md @@ -0,0 +1,46 @@ +# playground python frame + +multi-page farcaster frame built with python, framelib, flask, and vercel + +this sample frame demonstrates eip-712 signatures, text inputs, message validation, image rendering, and links + +## setup + +(optional, but recommended) set up a virtual environment (conda or other) +``` +conda create -n frame-example python=3.9 +conda activate frame-example +``` + +install dependencies +``` +pip install -r requirements.txt +``` + +setup vercel +``` +npm install vercel +``` + + +## development + +run local app +``` +npx vercel dev +``` + +you can run the frame debugger provided by [frames.js](https://github.com/framesjs/frames.js) to test locally + + +## deployment + +make sure your project has been pushed to a github repo + +import your project to [vercel](https://vercel.com/) with the flask framework + +register with [neynar](https://neynar.com/) to get an api key + +define the `NEYNAR_KEY` environment variable in your vercel project settings + +deploy! diff --git a/examples/playground/api/index.py b/examples/playground/api/index.py new file mode 100644 index 0000000..34abaa4 --- /dev/null +++ b/examples/playground/api/index.py @@ -0,0 +1,222 @@ +""" +main entry point for example framelib flask app +""" +import os +import time +from io import BytesIO + +from flask import Flask, url_for, jsonify, request, make_response +from PIL import Image, ImageDraw, ImageFont +from pydantic import BaseModel + +from framelib import ( + frame, + message, + signature, + validate_message_or_mock, + validate_message_or_mock_neynar, + error, + Address +) + +app = Flask(__name__) + + +@app.errorhandler(ValueError) +def handle_invalid_usage(e): + print(f'error: {e}') + return error(text=str(e), status=403) + + +@app.route('/', methods=['GET', 'POST']) +def page_home(): + # initial frame + return frame( + image=url_for( + 'render_image', + title='framelib', + msg='lightweight library for building farcaster frames\nin python\n\nby @conley', + _external=True + ), + button1='hello \U0001F44B', + post_url=url_for('page_hello', _external=True), + button2='github', + button2_action='link', + button2_target='https://github.com/devinaconley/python-framelib' + ) + + +@app.route('/hello', methods=['POST']) +def page_hello(): + # parse frame message + msg = message() + print(f'received frame message: {msg}') + + # check input + if msg.untrustedData.buttonIndex == 2: + print('invalid button input') + return error('wrong button!') # popup message to user + + # validate frame message with neynar + api_key = os.getenv('NEYNAR_KEY') + msg_neynar = validate_message_or_mock_neynar(msg, api_key, mock=_vercel_local()) + print(f'validated frame message, fid: {msg_neynar.interactor.fid}, button: {msg_neynar.tapped_button}') + + # validate frame message with hub (alternative) + # msg_hub = validate_message_or_mock(msg, 'https://nemes.farcaster.xyz:2281', mock=_vercel_local()) + # print(f'validated frame message hub, fid: {msg_hub.data.fid}, button: {msg_hub.data.frameActionBody.buttonIndex}') + + return frame( + image=url_for('render_image', title='hello', msg=f'hello {msg_neynar.interactor.username}!', _external=True), + button1='\U00002B05 home ', + post_url=url_for('page_home', _external=True), + button2='signature \U000027A1', + button2_target=url_for('page_signature', _external=True) + ) + + +@app.route('/signature', methods=['POST']) +def page_signature(): + # parse and validate frame message + api_key = os.getenv('NEYNAR_KEY') + msg = validate_message_or_mock_neynar(message(), api_key, mock=_vercel_local()) + print(f'verified frame message: {msg}') + + return frame( + image=url_for('render_image', title='signature', msg=f'sign an eip-712 message!', _external=True), + button1='hello \U0001F519', + button1_target=url_for('page_hello', _external=True), + post_url=url_for('handle_signature', _external=True), + input_text=f'enter a message to sign', + button2='\U00002712', + button2_action='tx', + button2_target=url_for('handle_signature', _external=True), + button3='puzzle \U000027A1', + button3_target=url_for('page_puzzle', _external=True) + ) + + +class User(BaseModel): + name: str + address: Address + + +class Message(BaseModel): + timestamp: int + user: User + text: str + + +@app.route('/signature/target', methods=['POST']) +def handle_signature(): + msg = message() + print(msg) + + if msg.untrustedData.transactionId is not None: + sig = msg.untrustedData.transactionId + print(f'received eip-712 signature: {sig}') + # note: verify signature here + + return frame( + image=url_for('render_image', title='signature', msg='thanks for signing.', _external=True), + button1='hello \U0001F519', + button1_target=url_for('page_hello', _external=True), + post_url=url_for('handle_signature', _external=True), + button2='puzzle \U000027A1', + button2_target=url_for('page_puzzle', _external=True) + ) + + api_key = os.getenv('NEYNAR_KEY') + msg_neynar = validate_message_or_mock_neynar(msg, api_key, mock=_vercel_local()) + + # setup eip-712 signature + payload = Message( + timestamp=int(time.time()), + user=User( + name=msg_neynar.interactor.username, + address=msg.untrustedData.address), + text=msg.untrustedData.inputText + ) + return signature(8453, payload, domain='playground', version='v1') + + +@app.route('/puzzle', methods=['POST']) +def page_puzzle(): + # parse frame message + msg = message() + print(f'received frame message: {msg}') + + # check input + if msg.untrustedData.inputText: + if msg.untrustedData.inputText.lower() != 'build': + return error('secret is incorrect!') # popup message to user + else: + return frame( + image=url_for('render_image', title='puzzle', msg='[the secret is build]', _external=True), + button1='signature \U0001F519', + post_url=url_for('page_signature', _external=True), + button2='links \U000027A1', + button2_target=url_for('page_link', _external=True) + ) + + return frame( + image=url_for('render_image', title='puzzle', msg='20 8 5 19 5 3 18 5 20 9 19 2 21 9 12 4', _external=True), + button1='signature \U0001F519', + button1_target=url_for('page_signature', _external=True), + post_url=url_for('page_puzzle', _external=True), + input_text=f'enter the secret', + button2='\U0001F512', + button3='links \U000027A1', + button3_target=url_for('page_link', _external=True) + ) + + +@app.route('/link', methods=['POST']) +def page_link(): + return frame( + image=_github_preview_image(), + button1='puzzle \U0001F519', + post_url=url_for('page_puzzle', _external=True), + button2='github \U0001F680', + button2_action='link', + button2_target='https://github.com/devinaconley/python-framelib' + ) + + +@app.route('/image') +def render_image(): + title = request.args.get('title', default='') + msg = request.args.get('msg', default='') + + # setup image background + image = Image.new('RGB', (764, 400), color=(211, 211, 211)) + draw = ImageDraw.Draw(image) + + # write text + font = ImageFont.truetype('DejaVuSansMono-Bold.ttf', 36) + draw.text((10, 10), title, fill=(0, 0, 0), font=font) + y = 80 + for m in msg.split('\n'): + font = ImageFont.truetype('DejaVuSansMono.ttf', 20) + draw.text((10, y), m, fill=(0, 0, 0), font=font) + y += 25 + + # encode image response + buffer = BytesIO() + image.save(buffer, format='PNG') + png_image_bytes = buffer.getvalue() + buffer.close() + + res = make_response(png_image_bytes) + res.headers.set('Content-Type', 'image/png') + return res + + +def _github_preview_image() -> str: + hour = int((time.time() // 3600) * 3600) # github throttles if you invalidate image cache too much + return f'https://opengraph.githubassets.com/{hour}/devinaconley/python-frames' + + +def _vercel_local() -> bool: + vercel_env = os.getenv('VERCEL_ENV') + return vercel_env is None or vercel_env == 'development' diff --git a/examples/playground/requirements.txt b/examples/playground/requirements.txt new file mode 100644 index 0000000..793625f --- /dev/null +++ b/examples/playground/requirements.txt @@ -0,0 +1,5 @@ +# requirements.txt +framelib~=1.0.0b0 +Flask~=3.0.1 +pillow~=11.0.0 +pydantic diff --git a/examples/playground/vercel.json b/examples/playground/vercel.json new file mode 100644 index 0000000..1484a9b --- /dev/null +++ b/examples/playground/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/index" + } + ] +} \ No newline at end of file diff --git a/examples/simple/api/index.py b/examples/simple/api/index.py index b979b99..84de8fe 100644 --- a/examples/simple/api/index.py +++ b/examples/simple/api/index.py @@ -2,7 +2,6 @@ main entry point for example framelib flask app """ import os -import time from flask import Flask, url_for, jsonify from framelib import frame, message, validate_message_or_mock, validate_message_or_mock_neynar, error @@ -19,13 +18,13 @@ def handle_invalid_usage(e): def home(): # initial frame return frame( - image=_github_preview_image(), + image='https://framelib.s3.us-east-1.amazonaws.com/framelib_logo.png', + aspect_ratio='1:1', button1='hello \U0001F44B', post_url=url_for('second_page', _external=True), - button2='do not press \U0001F6AB', - button3='github', - button3_action='link', - button3_target='https://github.com/devinaconley/python-frames' + button2='github', + button2_action='link', + button2_target='https://github.com/devinaconley/python-frames' ) @@ -35,22 +34,15 @@ def second_page(): msg = message() print(f'received frame message: {msg}') - # check input - if msg.untrustedData.buttonIndex == 2: - print('invalid button input') - return error('wrong button!') # popup message to user - # validate frame message with neynar api_key = os.getenv('NEYNAR_KEY') msg_neynar = validate_message_or_mock_neynar(msg, api_key, mock=_vercel_local()) print(f'validated frame message, fid: {msg_neynar.interactor.fid}, button: {msg_neynar.tapped_button}') - # validate frame message with hub (alternative) - # msg_hub = validate_message_or_mock(msg, 'https://nemes.farcaster.xyz:2281', mock=_vercel_local()) - # print(f'validated frame message hub, fid: {msg_hub.data.fid}, button: {msg_hub.data.frameActionBody.buttonIndex}') - + # second page frame return frame( - image=_github_preview_image(), + image='https://framelib.s3.us-east-1.amazonaws.com/framelib_logo.png', + aspect_ratio='1:1', button1='back \U0001F519', post_url=url_for('home', _external=True), input_text=f'hello {msg_neynar.interactor.username}!', @@ -60,11 +52,6 @@ def second_page(): ) -def _github_preview_image() -> str: - hour = int((time.time() // 3600) * 3600) # github throttles if you invalidate image cache too much - return f'https://opengraph.githubassets.com/{hour}/devinaconley/python-frames' - - def _vercel_local() -> bool: vercel_env = os.getenv('VERCEL_ENV') return vercel_env is None or vercel_env == 'development' diff --git a/examples/simple/requirements.txt b/examples/simple/requirements.txt index 15ce5e6..42ddb3e 100644 --- a/examples/simple/requirements.txt +++ b/examples/simple/requirements.txt @@ -1,4 +1,4 @@ # requirements.txt -framelib~=0.0.5 +framelib~=1.0.0b0 Flask~=3.0.1 pydantic diff --git a/examples/transaction/requirements.txt b/examples/transaction/requirements.txt index 15ce5e6..42ddb3e 100644 --- a/examples/transaction/requirements.txt +++ b/examples/transaction/requirements.txt @@ -1,4 +1,4 @@ # requirements.txt -framelib~=0.0.5 +framelib~=1.0.0b0 Flask~=3.0.1 pydantic diff --git a/framelib/__init__.py b/framelib/__init__.py index e338784..d13737d 100644 --- a/framelib/__init__.py +++ b/framelib/__init__.py @@ -4,10 +4,10 @@ from .frame import frame, message, error from .hub import validate_message, validate_message_or_mock -from .models import FrameMessage, ValidatedMessage, User +from .models import FrameMessage, ValidatedMessage, User, Address, Bytes, Bytes32 from .warpcast import get_user from .neynar import ( validate_message as validate_message_neynar, validate_message_or_mock as validate_message_or_mock_neynar ) -from .transaction import transaction +from .transaction import transaction, mint, signature diff --git a/framelib/models.py b/framelib/models.py index 76b686f..8133e6f 100644 --- a/framelib/models.py +++ b/framelib/models.py @@ -4,7 +4,8 @@ import datetime from typing import Optional, Literal -from pydantic import BaseModel +from pydantic import BaseModel, SerializeAsAny +from eth_utils import is_address # ---- frame message ---- @@ -46,8 +47,8 @@ class FrameMessage(BaseModel): class EthTransactionParams(BaseModel): abi: list[dict] to: str - value: Optional[str] - data: Optional[str] + value: Optional[str] = None + data: Optional[str] = None class Transaction(BaseModel): @@ -56,6 +57,54 @@ class Transaction(BaseModel): params: EthTransactionParams +# ---- signature ---- + +class Address(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, value, info): + if not is_address(value): + raise ValueError('invalid ethereum address') + return value + + +class Bytes32(str): + pass + + +class Bytes(str): + pass + + +class Eip712Domain(BaseModel): + name: Optional[str] = None + version: Optional[str] = None + chainId: Optional[int] = None + verifyingContract: Optional[Address] = None + salt: Optional[str] = None + + +class Eip712TypeField(BaseModel): + name: str + type: str + + +class Eip712Params(BaseModel): + domain: Eip712Domain + types: dict[str, list[Eip712TypeField]] + primaryType: str + message: SerializeAsAny[BaseModel] + + +class Signature(BaseModel): + chainId: str + method: Literal['eth_signTypedData_v4'] + params: Eip712Params + + # ---- frame error ---- class FrameError(BaseModel): diff --git a/framelib/transaction.py b/framelib/transaction.py index 4a37afc..66df8cb 100644 --- a/framelib/transaction.py +++ b/framelib/transaction.py @@ -3,21 +3,24 @@ """ # lib +from typing import Type from eth_abi import encode from eth_utils import is_address, function_signature_to_4byte_selector, function_abi_to_4byte_selector from flask import jsonify, Response +from pydantic import BaseModel # src -from .models import Transaction, EthTransactionParams +from .models import Transaction, EthTransactionParams, Address, Bytes, Bytes32, Eip712TypeField, Signature, \ + Eip712Domain, Eip712Params def transaction( - chain_id: int, - contract: str, - abi: list[dict], - value: str = None, - function_signature: str = None, - function_arguments: list = None + chain_id: int, + contract: str, + abi: list[dict], + value: str = None, + function_signature: str = None, + function_arguments: list = None ) -> Response: if not is_address(contract): raise ValueError(f'invalid contract address {contract}') @@ -51,3 +54,81 @@ def transaction( res = jsonify(tx.model_dump(mode='json', exclude_none=True)) res.status_code = 200 return res + + +def mint(chain_id: int, contract: str, token_id: int = None) -> str: + if not is_address(contract): + raise ValueError(f'invalid contract address {contract}') + + target = f'eip155:{chain_id}:{contract}' + if token_id is not None: + target += f':{token_id}' + + return target + + +def signature( + chain_id: int, + message: BaseModel, + domain: str = None, + version: str = None, + contract: str = None, + salt: str = None +) -> Response: + # collect custom types + def recurse_model_types(model: Type[BaseModel]): + types_ = {} + for name_, field_ in model.__annotations__.items(): + if not issubclass(field_, BaseModel): + continue + types_ = recurse_model_types(field_) + types_[field_.__name__] = field_ + types_[model.__name__] = model + return types_ + + types = recurse_model_types(message.__class__) + + primitives = { + 'int': 'uint256', + 'str': 'string', + 'bool': 'bool', + 'Address': 'address', + 'Bytes': 'bytes', + 'Bytes32': 'bytes32' + } + + # format eip712 type definitions + eip712_types = {} + for name, cls in types.items(): + fields = [] + for n, f in cls.__annotations__.items(): + t = f.__name__ + if t in primitives: + fields.append(Eip712TypeField(name=n, type=primitives[t])) + elif t in types: + fields.append(Eip712TypeField(name=n, type=t)) + else: + raise ValueError(f'unsupported field type {n} {t}') + eip712_types[name] = fields + + sig = Signature( + chainId=f'eip155:{chain_id}', + method='eth_signTypedData_v4', + params=Eip712Params( + domain=Eip712Domain( + name=domain, + version=version, + chainId=chain_id, + verifyingContract=contract, + salt=salt + ), + types=eip712_types, + primaryType=message.__class__.__name__, + message=message + ) + ) + + # response + res = jsonify(sig.model_dump(mode='json', exclude_none=True)) + res.status_code = 200 + return res diff --git a/setup.py b/setup.py index a5ecca8..aa67f60 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name='framelib', - version='0.0.5', + version='1.0.0b0', author='Devin A. Conley', author_email='devinaconley@gmail.com', description='lightweight library for building farcaster frames using python and flask', diff --git a/test/test_mint.py b/test/test_mint.py new file mode 100644 index 0000000..1ce7d4c --- /dev/null +++ b/test/test_mint.py @@ -0,0 +1,17 @@ +""" +test cases for frame minting logic +""" + +# src +from framelib import mint + + +class TestTransaction(object): + + def test_mint(self): + target = mint(7777777, '0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df') + assert target == 'eip155:7777777:0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df' + + def test_mint_with_id(self): + target = mint(7777777, '0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df', token_id=1234) + assert target == 'eip155:7777777:0x060f3edd18c47f59bd23d063bbeb9aa4a8fec6df:1234' diff --git a/test/test_signature.py b/test/test_signature.py new file mode 100644 index 0000000..cf2ed55 --- /dev/null +++ b/test/test_signature.py @@ -0,0 +1,140 @@ +""" +test cases for frame signature requests and verification +""" + +# lib +from pydantic import BaseModel +from flask import Flask + +# src +from framelib import signature, Address + +app = Flask(__name__) + + +class TestSignature(object): + + def test_signature_request(self): + class Message(BaseModel): + message: str + timestamp: int + + msg = Message(message='hello world', timestamp=1234567890) + + with app.app_context(): + res = signature(1, msg, domain='myprotocol') + + assert res.status_code == 200 + assert res.json['chainId'] == 'eip155:1' + assert res.json['method'] == 'eth_signTypedData_v4' + assert res.json['params']['domain']['name'] == 'myprotocol' + assert res.json['params']['domain']['chainId'] == 1 + assert 'salt' not in res.json['params']['domain'] + assert 'verifyingContract' not in res.json['params']['domain'] + + assert len(res.json['params']['types']) == 1 + assert res.json['params']['types']['Message'] == [ + {'name': 'message', 'type': 'string'}, + {'name': 'timestamp', 'type': 'uint256'} + ] + assert res.json['params']['primaryType'] == 'Message' + assert res.json['params']['message'] == {'message': 'hello world', 'timestamp': 1234567890} + + def test_signature_request_nested(self): + class User(BaseModel): + name: str + + class Message(BaseModel): + message: str + timestamp: int + sender: User + recipient: User + + msg = Message( + message='hello bob', + timestamp=1234567890, + sender=User(name='alice'), + recipient=User(name='bob') + ) + + with app.app_context(): + res = signature( + 8453, + msg, + domain='another_app', + version='v3', + contract='0x1234567890abcdef1234567890abcdef12345678', + salt='0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + ) + + assert res.status_code == 200 + assert res.json['chainId'] == 'eip155:8453' + assert res.json['method'] == 'eth_signTypedData_v4' + assert res.json['params']['domain']['name'] == 'another_app' + assert res.json['params']['domain']['version'] == 'v3' + assert res.json['params']['domain']['chainId'] == 8453 + assert res.json['params']['domain']['verifyingContract'] \ + == '0x1234567890abcdef1234567890abcdef12345678' + assert res.json['params']['domain']['salt'] \ + == '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef' + + assert len(res.json['params']['types']) == 2 + assert res.json['params']['types']['Message'] == [ + {'name': 'message', 'type': 'string'}, + {'name': 'timestamp', 'type': 'uint256'}, + {'name': 'sender', 'type': 'User'}, + {'name': 'recipient', 'type': 'User'} + ] + assert res.json['params']['types']['User'] == [ + {'name': 'name', 'type': 'string'} + ] + assert res.json['params']['primaryType'] == 'Message' + assert res.json['params']['message'] == { + 'message': 'hello bob', + 'timestamp': 1234567890, + 'sender': {'name': 'alice'}, + 'recipient': {'name': 'bob'} + } + + def test_signature_request_eth(self): + class Approval(BaseModel): + token: Address + limit: int + expiry: int + + msg = Approval( + token='0x4200000000000000000000000000000000000006', + limit=int(500e18), + expiry=1234567890 + ) + + with app.app_context(): + res = signature( + 8453, + msg, + domain='gasless_exchange', + contract='0x1234567890abcdef1234567890abcdef12345678', + ) + + assert res.status_code == 200 + assert res.json['chainId'] == 'eip155:8453' + assert res.json['method'] == 'eth_signTypedData_v4' + assert res.json['params']['domain']['name'] == 'gasless_exchange' + assert res.json['params']['domain']['chainId'] == 8453 + assert res.json['params']['domain']['verifyingContract'] \ + == '0x1234567890abcdef1234567890abcdef12345678' + assert 'salt' not in res.json['params']['domain'] + assert 'version' not in res.json['params']['domain'] + + assert len(res.json['params']['types']) == 1 + assert res.json['params']['types']['Approval'] == [ + {'name': 'token', 'type': 'address'}, + {'name': 'limit', 'type': 'uint256'}, + {'name': 'expiry', 'type': 'uint256'} + ] + assert res.json['params']['primaryType'] == 'Approval' + assert res.json['params']['message'] == { + 'token': '0x4200000000000000000000000000000000000006', + 'limit': 500e18, + 'expiry': 1234567890 + }