From 840910ed48bc2e2d8c2ff72fe227772650e6e7a5 Mon Sep 17 00:00:00 2001 From: Arthur Guillon Date: Mon, 13 Nov 2023 16:20:55 +0100 Subject: [PATCH 1/4] Remove GET /withdraw_counter, now returned by GET /users --- src/routes.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/routes.py b/src/routes.py index 241567d..c4c07a2 100644 --- a/src/routes.py +++ b/src/routes.py @@ -150,22 +150,6 @@ async def get_user(user_address: str, db: Session = Depends(database.get_db)): ) -@router.get("/withdraw_counter/{user_address}", - response_model=schemas.WithdrawCounter) -async def get_withdraw_counter(user_address: str, - db: Session = Depends(database.get_db)): - try: - counter = crud.get_user_by_address(db, user_address).withdraw_counter - if counter is None: - counter = 0 - return schemas.WithdrawCounter(counter=counter) - except UserNotFound: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"User not found.", - ) - - @router.get("/credits/{user_id}", response_model=list[schemas.Credit]) async def credits_for_user( user_id: str, db: Session = Depends(database.get_db) From 9ad97be5baf3026322401bb038ed94a7a3e559cf Mon Sep 17 00:00:00 2001 From: Arthur Guillon Date: Mon, 13 Nov 2023 16:22:02 +0100 Subject: [PATCH 2/4] Support UUID or address for most getters --- src/crud.py | 28 ++++++++++++++----- src/routes.py | 74 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/src/crud.py b/src/crud.py index e662808..28f5ec0 100644 --- a/src/crud.py +++ b/src/crud.py @@ -53,7 +53,7 @@ def get_contracts_by_credit(db: Session, credit_id: str): models.Contract.credit_id == credit_id).all() -def get_contract(db: Session, address: str): +def get_contract_by_address(db: Session, address: str): """ Return a models.Contract or raise ContractNotFound exception """ @@ -65,21 +65,37 @@ def get_contract(db: Session, address: str): raise ContractNotFound() from e -def get_entrypoints(db: Session, - contract_address: str) -> List[models.Entrypoint]: +def get_contract(db: Session, contract_id: str): + """ + Return a models.Contract or raise ContractNotFound exception + """ + try: + return db.query(models.Contract).get(contract_id) + except NoResultFound as e: + raise ContractNotFound() from e + + +def get_entrypoints( + db: Session, + contract_address_or_id: str +) -> List[models.Entrypoint]: """ Return a list of models.Contract or raise ContractNotFound exception """ - contract = get_contract(db, contract_address) + if contract_address_or_id.startswith("KT"): + contract = get_contract_by_address(db, contract_address_or_id) + else: + contract = get_contract(db, contract_address_or_id) return contract.entrypoints -def get_entrypoint(db: Session, contract_address: str, +def get_entrypoint(db: Session, + contract_address_or_id: str, name: str) -> Optional[models.Entrypoint]: """ Return a models.Entrypoint or raise EntrypointNotFound exception """ - entrypoints = get_entrypoints(db, contract_address) + entrypoints = get_entrypoints(db, contract_address_or_id) entrypoint = [e for e in entrypoints if e.name == name] # type: ignore if len(entrypoint) == 0: raise EntrypointNotFound() diff --git a/src/routes.py b/src/routes.py index c4c07a2..6d44750 100644 --- a/src/routes.py +++ b/src/routes.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from . import tezos from pytezos.rpc.errors import MichelsonError +from pytezos.crypto.encoding import is_address from .utils import ContractNotFound, CreditNotFound, EntrypointNotFound, UserNotFound @@ -139,10 +140,13 @@ async def withdraw_credits( # Users and credits getters -@router.get("/users/{user_address}", response_model=schemas.User) -async def get_user(user_address: str, db: Session = Depends(database.get_db)): +@router.get("/users/{address_or_id}", response_model=schemas.User) +async def get_user(address_or_id: str, db: Session = Depends(database.get_db)): try: - return crud.get_user_by_address(db, user_address) + if is_address(address_or_id) and address_or_id.startswith("tz"): + return crud.get_user_by_address(db, address_or_id) + else: + return crud.get_user(db, address_or_id) except UserNotFound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -150,12 +154,17 @@ async def get_user(user_address: str, db: Session = Depends(database.get_db)): ) -@router.get("/credits/{user_id}", response_model=list[schemas.Credit]) +@router.get("/credits/{user_address_or_id}", + response_model=list[schemas.Credit]) async def credits_for_user( - user_id: str, db: Session = Depends(database.get_db) + user_address_or_id: str, db: Session = Depends(database.get_db) ): try: - return crud.get_user(db, user_id).credits + if is_address(user_address_or_id) \ + and user_address_or_id.startswith("tz"): + return crud.get_user_by_address(db, user_address_or_id).credits + else: + return crud.get_user(db, user_address_or_id).credits except UserNotFound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -164,11 +173,13 @@ async def credits_for_user( # Contracts -@router.get("/contracts/user/{user_address}", response_model=list[schemas.Contract]) -async def get_user_contracts(user_address: str, db: Session = Depends(database.get_db)): +@router.get("/contracts/user/{user_address}", + response_model=list[schemas.Contract]) +async def get_user_contracts(user_address: str, + db: Session = Depends(database.get_db)): try: return crud.get_contracts_by_user(db, user_address) - except UserNotFound as e: + except UserNotFound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User not found." ) @@ -179,15 +190,19 @@ async def get_user_contracts(user_address: str, db: Session = Depends(database.g async def get_credit(credit_id: str, db: Session = Depends(database.get_db)): try: return crud.get_contracts_by_credit(db, credit_id) - except CreditNotFound as e: + except CreditNotFound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Credit not found." ) -@router.get("/contracts/{address}", response_model=schemas.Contract) -async def get_contract(address: str, db: Session = Depends(database.get_db)): - contract = crud.get_contract(db, address) +@router.get("/contracts/{address_or_id}", response_model=schemas.Contract) +async def get_contract(address_or_id: str, + db: Session = Depends(database.get_db)): + if is_address(address_or_id) and address_or_id.startswith("KT"): + contract = crud.get_contract_by_address(db, address_or_id) + else: + contract = crud.get_contract(db, address_or_id) if not contract: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Contract not found." @@ -196,24 +211,35 @@ async def get_contract(address: str, db: Session = Depends(database.get_db)): # Entrypoints -@router.get("/entrypoints/{contract_address}", response_model=list[schemas.Entrypoint]) +@router.get("/entrypoints/{contract_address_or_id}", + response_model=list[schemas.Entrypoint]) async def get_entrypoints( - contract_address: str, db: Session = Depends(database.get_db) + contract_address_or_id: str, db: Session = Depends(database.get_db) ): try: - return crud.get_entrypoints(db, contract_address) - except ContractNotFound as e: + if contract_address_or_id.startswith("KT"): + assert is_address(contract_address_or_id) + return crud.get_entrypoints(db, contract_address_or_id) + except ContractNotFound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Contract not found." ) + except AssertionError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid address." + ) + -@router.get("/entrypoints/{contract_address}/{name}", response_model=schemas.Entrypoint) +@router.get("/entrypoints/{contract_address_or_id}/{name}", + response_model=schemas.Entrypoint) async def get_entrypoint( - contract_address: str, name: str, db: Session = Depends(database.get_db) + contract_address_or_id: str, + name: str, + db: Session = Depends(database.get_db) ): try: - return crud.get_entrypoint(db, contract_address, name) - except EntrypointNotFound as e: + return crud.get_entrypoint(db, contract_address_or_id, name) + except EntrypointNotFound: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Entrypoint not found." ) @@ -240,7 +266,7 @@ async def post_operation( detail=f"Target {contract_address} is not allowed", ) try: - contract = crud.get_contract(db, contract_address) + contract = crud.get_contract_by_address(db, contract_address) except ContractNotFound: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -250,9 +276,7 @@ async def post_operation( entrypoint_name = operation["parameters"]["entrypoint"] print(contract_address, entrypoint_name) try: - entrypoint = crud.get_entrypoint(db, - str(contract.address), - entrypoint_name) + crud.get_entrypoint(db, str(contract.address), entrypoint_name) except EntrypointNotFound: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, From 816f915e0680c5eba15c5e77f530ef1cea9d59d5 Mon Sep 17 00:00:00 2001 From: Arthur Guillon Date: Mon, 13 Nov 2023 17:37:09 +0100 Subject: [PATCH 3/4] fix: use HTTP 400 instead of 422/409 --- src/routes.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/routes.py b/src/routes.py index 6d44750..ffe7df7 100644 --- a/src/routes.py +++ b/src/routes.py @@ -62,7 +62,7 @@ async def update_credits( amount) if not is_confirmed: raise HTTPException( - status_code=status.HTTP_409_CONFLICT, + status_code=status.HTTP_404_NOT_FOUND, detail=f"Could not find confirmation for {amount} with {op_hash}" ) return crud.update_credits(db, credits) @@ -96,14 +96,14 @@ async def withdraw_credits( ) if credits.amount < withdraw.amount: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail="Not enough funds to withdraw." ) expected_counter = credits.owner.withdraw_counter or 0 if expected_counter != withdraw.withdraw_counter: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail="Bad withdraw counter." ) @@ -115,7 +115,7 @@ async def withdraw_credits( public_key) if not is_valid: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid signature." ) # We increment the counter even if the withdraw fails to prevent @@ -252,7 +252,7 @@ async def post_operation( ): if len(call_data.operations) == 0: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Empty operations list", ) # TODO: check that amount=0? @@ -262,14 +262,14 @@ async def post_operation( # Transfers to implicit accounts are always refused if not contract_address.startswith("KT"): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Target {contract_address} is not allowed", ) try: contract = crud.get_contract_by_address(db, contract_address) except ContractNotFound: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Target {contract_address} is not allowed", ) @@ -279,7 +279,7 @@ async def post_operation( crud.get_entrypoint(db, str(contract.address), entrypoint_name) except EntrypointNotFound: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Entrypoint {entrypoint_name} is not allowed", ) @@ -292,7 +292,7 @@ async def post_operation( estimated_fees = tezos.group_fees(op_estimated_fees) if not tezos.check_credits(db, estimated_fees): raise HTTPException( - status_code=status.HTTP_409_CONFLICT, + status_code=status.HTTP_400_BAD_REQUEST, detail="Not enough funds." ) result = await tezos.tezos_manager.queue_operation(call_data.sender, @@ -302,7 +302,7 @@ async def post_operation( print(e) raise HTTPException( # FIXME? Is this the best one? - status_code=status.HTTP_409_CONFLICT, + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Operation is invalid", ) except Exception: From cfe7447c281692a80675c8d7d31887511893ad4a Mon Sep 17 00:00:00 2001 From: Arthur Guillon Date: Tue, 21 Nov 2023 15:57:43 +0100 Subject: [PATCH 4/4] Support POST signed_operations, but without the contract's address. --- src/routes.py | 31 ++++++++++++++++++++++++++++--- src/schemas.py | 13 ++++++++++--- src/tezos.py | 27 +++++++++++++++++---------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/routes.py b/src/routes.py index ffe7df7..870d553 100644 --- a/src/routes.py +++ b/src/routes.py @@ -248,7 +248,7 @@ async def get_entrypoint( # Operations @router.post("/operation") async def post_operation( - call_data: schemas.CallData, db: Session = Depends(database.get_db) + call_data: schemas.UnsignedCall, db: Session = Depends(database.get_db) ): if len(call_data.operations) == 0: raise HTTPException( @@ -295,8 +295,10 @@ async def post_operation( status_code=status.HTTP_400_BAD_REQUEST, detail="Not enough funds." ) - result = await tezos.tezos_manager.queue_operation(call_data.sender, - op) + result = await tezos.tezos_manager.queue_operation( + call_data.sender_address, + op + ) except MichelsonError as e: print("Received failing operation, discarding") print(e) @@ -312,3 +314,26 @@ async def post_operation( detail=f"Unknown exception raised.", ) return result + + +@router.post("/signed_operation") +async def signed_operation( + call_data: schemas.SignedCall, db: Session = Depends(database.get_db) +): + # In order for the user to sign Micheline, we need to + # FIXME: this is a serious issue, we should sign the contract address too. + signed_data = [x["parameters"]["value"] for x in call_data.operations] + if not tezos.check_signature( + signed_data, + call_data.signature, + call_data.sender_key, + call_data.micheline_type + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid signature." + ) + address = tezos.public_key_hash(call_data.sender_key) + call_data = schemas.UnsignedCall(sender_address=address, + operations=call_data.operations) + return await post_operation(call_data, db) diff --git a/src/schemas.py b/src/schemas.py index 49e8d28..4fb6cd6 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -88,8 +88,15 @@ class ContractCreation(ContractBase): credit_id: UUID4 # Operations -# TODO: right now the sender isn't checked, as we use permits anyway -class CallData(BaseModel): - sender: str +class UnsignedCall(BaseModel): + """Data sent when posting an operation. The sender is mandatory.""" + sender_address: str operations: list[dict[str, Any]] + +class SignedCall(BaseModel): + """Data sent when posting an operation. The signature""" + sender_key: str + operations: list[dict[str, Any]] + signature: str + micheline_type: Any diff --git a/src/tezos.py b/src/tezos.py index d2405ba..cefa494 100644 --- a/src/tezos.py +++ b/src/tezos.py @@ -123,16 +123,17 @@ def get_public_key(address): return key -def check_signature(pair_data, signature, public_key): - # Type of a withdraw operation - pair_type = { - "prim": 'pair', - "args": [ - {"prim": 'string'}, - {"prim": "int"}, - {"prim": 'mutez'} - ] - } +def check_signature(pair_data, signature, public_key, pair_type=None): + if pair_type is None: + # Type of a withdraw operation + pair_type = { + "prim": 'pair', + "args": [ + {"prim": 'string'}, + {"prim": "int"}, + {"prim": 'mutez'} + ] + } public_key = pytezos.Key.from_encoded_key(public_key) matcher = MichelsonType.match(pair_type) packed_pair = matcher.from_micheline_value(pair_data).pack() @@ -143,6 +144,11 @@ def check_signature(pair_data, signature, public_key): return False +def public_key_hash(public_key: str): + key = pytezos.Key.from_encoded_key(public_key) + return key.public_key_hash() + + async def withdraw(tezos_manager, to, amount): op = ptz.transaction(source=ptz.key.public_key_hash(), destination=to, @@ -160,6 +166,7 @@ def __init__(self, ptz): # Receive an operation from sender and add it to the waiting queue; # blocks until there is a result in self.results async def queue_operation(self, sender, operation): + print(operation) self.results[sender] = "waiting" self.ops_queue[sender] = operation while self.results[sender] == "waiting":