diff --git a/api-usage.ipynb b/api-usage.ipynb new file mode 100644 index 0000000..07db467 --- /dev/null +++ b/api-usage.ipynb @@ -0,0 +1,621 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "441aaa0c", + "metadata": {}, + "outputs": [], + "source": [ + "import pytezos\n", + "from pytezos.contract.interface import ContractInterface" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "0c834381", + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "import json\n", + "from dotenv import load_dotenv\n", + "import os" + ] + }, + { + "cell_type": "markdown", + "id": "799f697b", + "metadata": {}, + "source": [ + "# Testing Gas station API" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "60855c5c", + "metadata": {}, + "outputs": [], + "source": [ + "gas_station_api = \"http://localhost:8000\"\n", + "contract_address = \"KT1HUdxmgZUw21ED9gqELVvCty5d1ff41p7J\" # permit NFT contract on Ghostnet" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0d1e9f63", + "metadata": {}, + "outputs": [], + "source": [ + "alice_key = \"edskRpm2mUhvoUjHjXgMoDRxMKhtKfww1ixmWiHCWhHuMEEbGzdnz8Ks4vgarKDtxok7HmrEo1JzkXkdkvyw7Rtw6BNtSd7MJ7\"\n", + "alice = pytezos.Key.from_encoded_key(alice_key)\n", + "ptz = pytezos.pytezos.using(\"https://ghostnet.tezos.marigold.dev\", alice)" + ] + }, + { + "cell_type": "markdown", + "id": "b227d685", + "metadata": {}, + "source": [ + "We assume that alice has not been already registered as a user. If she was, skip this." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "23eff5ca", + "metadata": {}, + "outputs": [], + "source": [ + "r = requests.post(\n", + " f\"{gas_station_api}/users\",\n", + " json = {\n", + " \"address\": alice.public_key_hash(),\n", + " \"name\": \"alice\"\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2e5d1f47", + "metadata": {}, + "outputs": [], + "source": [ + "assert r.status_code == 200" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "edb869bd", + "metadata": {}, + "outputs": [], + "source": [ + "r = requests.get(f\"{gas_station_api}/users/{alice.public_key_hash()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "e1596858", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'address': 'tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb',\n", + " 'id': '06d44229-4b75-4df5-9bac-df3b53285859',\n", + " 'name': 'alice',\n", + " 'withdraw_counter': 0}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "alice_user = r.json()\n", + "alice_user" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cec218c1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'id': '958caaa8-ed25-4c26-a062-42bd78182399',\n", + " 'amount': 0,\n", + " 'owner_id': '06d44229-4b75-4df5-9bac-df3b53285859'}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r = requests.get(f\"{gas_station_api}/credits/{alice_user['address']}\")\n", + "credits = r.json()[0]\n", + "assert r.status_code == 200\n", + "credits" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bf71169b", + "metadata": {}, + "outputs": [], + "source": [ + "register_contract = {\n", + " \"address\": contract_address,\n", + " \"owner_id\": alice_user[\"id\"],\n", + " \"name\": \"NFT1\",\n", + " \"entrypoints\": [\n", + " {\n", + " \"name\": \"mint_token\",\n", + " \"is_enabled\": True\n", + " },\n", + " {\n", + " \"name\": \"transfer\",\n", + " \"is_enabled\": True\n", + " },\n", + " {\n", + " \"name\": \"permit\",\n", + " \"is_enabled\": True\n", + " }\n", + " ],\n", + " \"credit_id\": credits[\"id\"]\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ecc30c7a", + "metadata": {}, + "outputs": [], + "source": [ + "r = requests.post(\n", + " f\"{gas_station_api}/contracts\",\n", + " json = register_contract\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "3a285211", + "metadata": {}, + "outputs": [], + "source": [ + "assert r.status_code == 200" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "a2d993c0", + "metadata": {}, + "outputs": [], + "source": [ + "api_info = requests.get(\n", + " f\"{gas_station_api}/\"\n", + ").json()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "729037fe", + "metadata": {}, + "outputs": [], + "source": [ + "tr = ptz.transaction(destination=api_info[\"tezos_address\"], amount=int(1e6)).send()" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "27753cbf", + "metadata": {}, + "outputs": [], + "source": [ + "credits_deposit = {\n", + " \"id\": credits[\"id\"],\n", + " \"amount\": int(1e6),\n", + " \"operation_hash\": tr.hash(),\n", + " \"owner_id\": alice_user[\"id\"]\n", + "}\n", + "r = requests.put(\n", + " f\"{gas_station_api}/deposit\",\n", + " json = credits_deposit\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "bcba5181", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "200" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.status_code" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "6d9af87b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'id': '958caaa8-ed25-4c26-a062-42bd78182399',\n", + " 'amount': 1000000,\n", + " 'owner_id': '06d44229-4b75-4df5-9bac-df3b53285859'}]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r = requests.get(\n", + " f\"{gas_station_api}/credits/{alice_user['id']}\"\n", + ")\n", + "r.json()" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "a83d8047", + "metadata": {}, + "outputs": [], + "source": [ + "ct1 = ptz.contract(contract_address)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "eabaebdc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "14\n" + ] + } + ], + "source": [ + "try:\n", + " print(ct1.storage[\"ledger\"][(\"tz1UtRfZGHjuHe5PJ7QpksEnn1K7BMJ4qVok\", 2)]())\n", + "except:\n", + " print(0)" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "fd1734cd", + "metadata": {}, + "outputs": [], + "source": [ + "op_content = ct1.mint_token([{\n", + " \"owner\": \"tz1UtRfZGHjuHe5PJ7QpksEnn1K7BMJ4qVok\",\n", + " \"token_id\": 2,\n", + " \"amount_\": 1\n", + "}]).as_transaction().contents" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "140b1471", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "200" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "operation = {\n", + " \"sender_address\": \"tz1UtRfZGHjuHe5PJ7QpksEnn1K7BMJ4qVok\",\n", + " \"operations\": op_content\n", + "}\n", + "r = requests.post(\n", + " f\"{gas_station_api}/operation\",\n", + " json=operation\n", + ")\n", + "r.status_code" + ] + }, + { + "cell_type": "markdown", + "id": "476f9bd6", + "metadata": {}, + "source": [ + "Once the API has received the operation, we check the balance of the user for this token. After a while, it's showing as updated." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "8ff9a023", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "15\n" + ] + } + ], + "source": [ + "try:\n", + " print(ct1.storage[\"ledger\"][(\"tz1UtRfZGHjuHe5PJ7QpksEnn1K7BMJ4qVok\", 2)]())\n", + "except:\n", + " print(0)" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "5508e9bc", + "metadata": {}, + "outputs": [], + "source": [ + "r = requests.get(\n", + " f\"{gas_station_api}/contracts/{contract_address}\"\n", + ")\n", + "contract_db = r.json()" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "0cbecd7f", + "metadata": {}, + "outputs": [], + "source": [ + "r = requests.post(\n", + " f\"{gas_station_api}/condition\",\n", + " json={\n", + " \"type\": \"MAX_CALLS_PER_SPONSEE\",\n", + " \"contract_id\": contract_db[\"id\"],\n", + " \"entrypoint_id\": None,\n", + " \"vault_id\": credits[\"id\"],\n", + " \"max\": 1\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "33cbdddf", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r" + ] + }, + { + "cell_type": "markdown", + "id": "b39438f0", + "metadata": {}, + "source": [ + "Now that we have a condition, new users can use the service only a limited number of times. The first time we call `mint_token` with the same address, everything should work, as the condition is not retroactive." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "202834f6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "200" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "operation = {\n", + " \"sender_address\": \"tz1UtRfZGHjuHe5PJ7QpksEnn1K7BMJ4qVok\",\n", + " \"operations\": op_content\n", + "}\n", + "r = requests.post(\n", + " f\"{gas_station_api}/operation\",\n", + " json=operation\n", + ")\n", + "r.status_code" + ] + }, + { + "cell_type": "markdown", + "id": "0d60d440", + "metadata": {}, + "source": [ + "But the second time it will fail:" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "b34ffa8d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "400" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "operation = {\n", + " \"sender_address\": \"tz1UtRfZGHjuHe5PJ7QpksEnn1K7BMJ4qVok\",\n", + " \"operations\": op_content\n", + "}\n", + "r = requests.post(\n", + " f\"{gas_station_api}/operation\",\n", + " json=operation\n", + ")\n", + "r.status_code" + ] + }, + { + "cell_type": "markdown", + "id": "4398dcad", + "metadata": {}, + "source": [ + "However, for another user it still works. Note that we only changed sender_address, and kept op_content the same, so the user receiving the new NFT is still the same." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "166eb0a0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "200" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "operation = {\n", + " \"sender_address\": \"tz1aH7Hj1s95wkPjCbMr2RMgTPq4RPHE1LLU\",\n", + " \"operations\": op_content\n", + "}\n", + "r = requests.post(\n", + " f\"{gas_station_api}/operation\",\n", + " json=operation\n", + ")\n", + "r.status_code" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "9daae29e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'type': 'MAX_CALLS_PER_SPONSEE',\n", + " 'vault_id': '958caaa8-ed25-4c26-a062-42bd78182399',\n", + " 'created_at': '2024-03-12T18:00:11.362107+00:00',\n", + " 'id': '928a842a-68da-48c6-b9fa-cccd71ccacb1',\n", + " 'contract_id': '4b0a9fdf-d36a-4ce8-af4c-1facc8ae1371',\n", + " 'entrypoint_id': None,\n", + " 'max': 1,\n", + " 'current': 2,\n", + " 'is_active': True}]" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "requests.get(\n", + " f\"{gas_station_api}/condition/{credits['id']}\"\n", + ").json()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "01e26527", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/crud.py b/src/crud.py index 3b7d98c..be73a5c 100644 --- a/src/crud.py +++ b/src/crud.py @@ -254,7 +254,7 @@ def get_credits_from_contract_address(db: Session, contract_address: str): def create_operation(db: Session, operation: schemas.CreateOperation): db_operation = models.Operation( **{ - "user_address": operation.user_address, + "sender_address": operation.sender_address, "contract_id": operation.contract_id, "entrypoint_id": operation.entrypoint_id, "hash": operation.hash, @@ -317,7 +317,7 @@ def create_max_calls_per_sponsee_condition( # If a condition still exists, do not create a new one existing_condition = ( db.query(models.Condition) - .filter(models.Condition.sponsee_address == condition.sponsee_address) + .filter(models.Condition.contract_id == condition.contract_id) .filter(models.Condition.vault_id == condition.vault_id) .filter(models.Condition.current < models.Condition.max) .one_or_none() @@ -329,17 +329,18 @@ def create_max_calls_per_sponsee_condition( db_condition = models.Condition( **{ "type": schemas.ConditionType.MAX_CALLS_PER_SPONSEE, - "sponsee_address": condition.sponsee_address, + "contract_id": condition.contract_id, "vault_id": condition.vault_id, "max": condition.max, + "is_active": True, "current": 0, } ) db.add(db_condition) db.commit() db.refresh(db_condition) - return schemas.MaxCallsPerSponseeCondition( - sponsee_address=db_condition.sponsee_address, + return schemas.ConditionBase( + contract_id=db_condition.contract_id, vault_id=db_condition.vault_id, max=db_condition.max, current=db_condition.current, @@ -390,12 +391,16 @@ def create_max_calls_per_entrypoint_condition( ) -def check_max_calls_per_sponsee(db: Session, sponsee_address: str, vault_id: UUID4): +def check_max_calls_per_sponsee( + db: Session, contract_id: UUID4, vault_id: UUID4 +): return ( db.query(models.Condition) .filter(models.Condition.type == schemas.ConditionType.MAX_CALLS_PER_SPONSEE) - .filter(models.Condition.sponsee_address == sponsee_address) + .filter(models.Condition.contract_id == contract_id) .filter(models.Condition.vault_id == vault_id) + .filter(models.Condition.is_active == True) + .order_by(models.Condition.created_at.asc()) .one_or_none() ) @@ -409,48 +414,65 @@ def check_max_calls_per_entrypoint( .filter(models.Condition.contract_id == contract_id) .filter(models.Condition.entrypoint_id == entrypoint_id) .filter(models.Condition.vault_id == vault_id) + .filter(models.Condition.is_active == True) .one_or_none() ) -def check_conditions(db: Session, datas: schemas.CheckConditions): - print(datas) +def update_condition(db: Session, condition: models.Condition): + db.query(models.Condition).filter(models.Condition.id == condition.id).update( + {"current": condition.current + 1} + ) + + +def check_max_sponsee_condition( + db: Session, data: schemas.CheckConditions, sponsee_condition: models.Condition +): + nb_operations = ( + db.query(models.Operation) + .filter(models.Operation.sender_address == data.sponsee_address) + .filter(models.Operation.contract_id == data.contract_id) + .filter(models.Operation.created_at >= sponsee_condition.created_at) + .count() + ) + print(nb_operations) + return nb_operations >= sponsee_condition.max + + +def check_conditions(db: Session, data: schemas.CheckConditions): + print(data) sponsee_condition = check_max_calls_per_sponsee( - db, datas.sponsee_address, datas.vault_id + db, data.contract_id, data.vault_id ) entrypoint_condition = check_max_calls_per_entrypoint( - db, datas.contract_id, datas.entrypoint_id, datas.vault_id + db, data.contract_id, data.entrypoint_id, data.vault_id ) # No condition registered if sponsee_condition is None and entrypoint_condition is None: return True - # One of condition is excedeed + # Check max_entrypoint condition if ( - sponsee_condition is not None - and (sponsee_condition.current >= sponsee_condition.max) - ) or ( - entrypoint_condition is not None - and (entrypoint_condition.current >= entrypoint_condition.max) + entrypoint_condition is not None and + (entrypoint_condition.current >= entrypoint_condition.max) ): return False - # Update conditions - # TODO - Rewrite with list + # Check max_sponsee condition + if ( + sponsee_condition is not None and + check_max_sponsee_condition(db, data, sponsee_condition) + ): + return False - if sponsee_condition: - update_condition(db, sponsee_condition) - if entrypoint_condition: + # Update conditions + if entrypoint_condition is not None: update_condition(db, entrypoint_condition) + if sponsee_condition is not None: + update_condition(db, sponsee_condition) return True -def update_condition(db: Session, condition: models.Condition): - db.query(models.Condition).filter(models.Condition.id == condition.id).update( - {"current": condition.current + 1} - ) - - def get_conditions_by_vault(db: Session, vault_id: str): return ( db.query(models.Condition).filter(models.Condition.vault_id == vault_id).all() diff --git a/src/models.py b/src/models.py index b0a7220..42edec7 100644 --- a/src/models.py +++ b/src/models.py @@ -112,7 +112,7 @@ class Operation(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) cost = Column(Integer) - user_address = Column(String) + sender_address = Column(String) contract_id = Column(UUID(as_uuid=True), ForeignKey("contracts.id")) entrypoint_id = Column(UUID(as_uuid=True), ForeignKey("entrypoints.id")) hash = Column(String) @@ -131,18 +131,11 @@ class Condition(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) type = Column(Enum(ConditionType)) - sponsee_address = Column( - String, - CheckConstraint( - "(type = 'MAX_CALLS_PER_SPONSEE') = (sponsee_address IS NOT NULL)", - name="sponsee_address_not_null_constraint", - ), - nullable=True, - ) contract_id = Column( UUID(as_uuid=True), CheckConstraint( - "(type = 'MAX_CALLS_PER_ENTRYPOINT') = (contract_id IS NOT NULL)", + "(type = 'MAX_CALLS_PER_ENTRYPOINT' or type = \ + 'MAX_CALLS_PER_SPONSEE') = (contract_id IS NOT NULL)", name="contract_id_not_null_constraint", ), ForeignKey("contracts.id"), @@ -151,7 +144,8 @@ class Condition(Base): entrypoint_id = Column( UUID(as_uuid=True), CheckConstraint( - "(type = 'MAX_CALLS_PER_ENTRYPOINT') = (entrypoint_id IS NOT NULL)", + "(type = 'MAX_CALLS_PER_ENTRYPOINT') = \ + (entrypoint_id IS NOT NULL)", name="entrypoint_id_not_null_constraint", ), ForeignKey("entrypoints.id"), @@ -163,7 +157,7 @@ class Condition(Base): created_at = Column( DateTime(timezone=True), default=datetime.datetime.utcnow(), nullable=False ) - + is_active = Column(Boolean, nullable=False) contract = relationship("Contract", back_populates="conditions") entrypoint = relationship("Entrypoint", back_populates="conditions") vault = relationship("Credit", back_populates="conditions") diff --git a/src/routes.py b/src/routes.py index 6873a79..b45d6ea 100644 --- a/src/routes.py +++ b/src/routes.py @@ -361,7 +361,11 @@ async def post_operation( crud.create_operation( db, schemas.CreateOperation( - user_address=call_data.sender_address, contract_id=str(contract.id), entrypoint_id=str(entrypoint.id), hash=result["transaction_hash"], status=result["result"] # type: ignore + sender_address=call_data.sender_address, + contract_id=str(contract.id), + entrypoint_id=str(entrypoint.id), + hash=result["transaction_hash"], + status=result["result"] # type: ignore ), ) except MichelsonError as e: @@ -448,12 +452,12 @@ async def create_condition( ) elif ( body.type == ConditionType.MAX_CALLS_PER_SPONSEE - and body.sponsee_address is not None + and body.contract_id is not None ): return crud.create_max_calls_per_sponsee_condition( db, schemas.CreateMaxCallsPerSponseeCondition( - sponsee_address=body.sponsee_address, + contract_id=body.contract_id, vault_id=body.vault_id, max=body.max, ), diff --git a/src/schemas.py b/src/schemas.py index c7db896..8143cfa 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -6,7 +6,9 @@ # -- UTILITY TYPES -- class ConditionType(enum.Enum): + # Max number of calls to a given entrypoint, for all sponsee MAX_CALLS_PER_ENTRYPOINT = "MAX_CALLS_PER_ENTRYPOINT" + # Max number of calls per sponsee per contract MAX_CALLS_PER_SPONSEE = "MAX_CALLS_PER_SPONSEE" @@ -125,7 +127,7 @@ class SignedCall(BaseModel): class CreateOperation(BaseModel): - user_address: str + sender_address: str contract_id: str entrypoint_id: str hash: str @@ -139,7 +141,6 @@ class UpdateMaxCallsPerMonth(BaseModel): class CreateCondition(BaseModel): type: ConditionType - sponsee_address: Optional[str] = None contract_id: Optional[UUID4] = None entrypoint_id: Optional[UUID4] = None vault_id: UUID4 @@ -154,7 +155,7 @@ class CreateMaxCallsPerEntrypointCondition(BaseModel): class CreateMaxCallsPerSponseeCondition(BaseModel): - sponsee_address: str + contract_id: UUID4 vault_id: UUID4 max: int @@ -168,6 +169,7 @@ class CheckConditions(BaseModel): class ConditionBase(BaseModel): vault_id: UUID4 + contract_id: UUID4 max: int current: int type: ConditionType @@ -176,9 +178,4 @@ class ConditionBase(BaseModel): class MaxCallsPerEntrypointCondition(ConditionBase): - contract_id: UUID4 entrypoint_id: UUID4 - - -class MaxCallsPerSponseeCondition(ConditionBase): - sponsee_address: str