commit 943f4d43d797415e9c5c799736c568b79c9e97e8 Author: jbell Date: Sun Oct 6 16:06:16 2024 -0400 Initial commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..d4c7b6b --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/httpClient.xml b/.idea/httpClient.xml new file mode 100644 index 0000000..7417105 --- /dev/null +++ b/.idea/httpClient.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..97ff0e9 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..201d106 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/puffpastry.iml b/.idea/puffpastry.iml new file mode 100644 index 0000000..2c80e12 --- /dev/null +++ b/.idea/puffpastry.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..f967db1 Binary files /dev/null and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__pycache__/api.cpython-312.pyc b/app/api/v1/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..345d0f3 Binary files /dev/null and b/app/api/v1/__pycache__/api.cpython-312.pyc differ diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..ad85735 --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import issue, user + +api_router = APIRouter() + +api_router.include_router(issue.router, prefix="/issues", tags=["users"]) +api_router.include_router(user.router, prefix="/user", tags=["users"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__pycache__/issue.cpython-312.pyc b/app/api/v1/endpoints/__pycache__/issue.cpython-312.pyc new file mode 100644 index 0000000..47d569b Binary files /dev/null and b/app/api/v1/endpoints/__pycache__/issue.cpython-312.pyc differ diff --git a/app/api/v1/endpoints/__pycache__/user.cpython-312.pyc b/app/api/v1/endpoints/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..1fbdae0 Binary files /dev/null and b/app/api/v1/endpoints/__pycache__/user.cpython-312.pyc differ diff --git a/app/api/v1/endpoints/comment.py b/app/api/v1/endpoints/comment.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/issue.py b/app/api/v1/endpoints/issue.py new file mode 100644 index 0000000..50b2266 --- /dev/null +++ b/app/api/v1/endpoints/issue.py @@ -0,0 +1,63 @@ +import pika +from fastapi import APIRouter + +from datetime import datetime + +from app.core.config import settings + +from app.contracts.issue import IssueContract +from app.crud.user import get_user +from app.db.session import get_db_session +from app.schemas.issue import IssuePost, AddIssueRequest, VoteIssueRequest, AddIssueResponse + +router = APIRouter() + + +@router.get("/list", response_model=list[IssuePost]) +async def list_issues(): + smart_contract = IssueContract( + settings.CONTRACT_ID, + settings.WALLET_ID + ) + issue_list = await smart_contract.list_issues() + return issue_list + + +@router.get("/paragraphs") +async def get_paragraphs(issue_id: str): + smart_contract = IssueContract( + settings.CONTRACT_ID, + settings.WALLET_ID + ) + paragraphs = await smart_contract.get_paragraphs(bytes.fromhex(issue_id)) + return paragraphs + + +@router.post("/create", response_model=AddIssueResponse) +async def create_issue(request: AddIssueRequest): + smart_contract = IssueContract( + settings.CONTRACT_ID, + settings.WALLET_ID + ) + async for session in get_db_session(): + user = await get_user(session, request.session_id) + time_since_auth = datetime.now() - datetime.fromtimestamp(user.auth_date) + if user and time_since_auth.total_seconds() < settings.AUTH_TIMEOUT: + await smart_contract.add_issue(request.title, request.paragraphs, user.username) + return {'result': True} + else: + return {'result': False} + return {'result': False} + + +@router.post("/vote") +async def vote_issue(request: VoteIssueRequest): + smart_contract = IssueContract( + settings.CONTRACT_ID, + settings.WALLET_ID + ) + if request.increase and not request.decrease: + await smart_contract.increase_vote(bytes.fromhex(request.issue_id)) + elif request.decrease and not request.increase: + await smart_contract.decrease_vote(bytes.fromhex(request.issue_id)) + return diff --git a/app/api/v1/endpoints/user.py b/app/api/v1/endpoints/user.py new file mode 100644 index 0000000..0ea142d --- /dev/null +++ b/app/api/v1/endpoints/user.py @@ -0,0 +1,20 @@ +from typing import Optional + +from fastapi import APIRouter +from uuid import uuid4 + +from app.crud.user import add_user, get_user +from app.db.session import get_db_session +from app.schemas.user import User + +router = APIRouter() + + +@router.post("/authenticate") +async def authenticate(user: dict): + session_id = uuid4() + session_obj = user + session_obj['session_id'] = str(session_id) + async for session in get_db_session(): + await add_user(session, session_obj) + return {'session_id': session_id} diff --git a/app/contracts/__pycache__/contract.cpython-312.pyc b/app/contracts/__pycache__/contract.cpython-312.pyc new file mode 100644 index 0000000..fe33a67 Binary files /dev/null and b/app/contracts/__pycache__/contract.cpython-312.pyc differ diff --git a/app/contracts/__pycache__/issue.cpython-312.pyc b/app/contracts/__pycache__/issue.cpython-312.pyc new file mode 100644 index 0000000..8466df9 Binary files /dev/null and b/app/contracts/__pycache__/issue.cpython-312.pyc differ diff --git a/app/contracts/contract.py b/app/contracts/contract.py new file mode 100644 index 0000000..c5a686a --- /dev/null +++ b/app/contracts/contract.py @@ -0,0 +1,106 @@ +import asyncio + +from stellar_sdk import Keypair, Network, SorobanServer, TransactionBuilder, xdr as stellar_xdr +from stellar_sdk.exceptions import PrepareTransactionException +from stellar_sdk.soroban_rpc import GetTransactionStatus, SendTransactionStatus +from stellar_sdk.xdr import SCVal, SCValType + + +class SingletonMeta(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class SmartContract(metaclass=SingletonMeta): + contract_id: str + user_key: str + def __init__(self, contract_id: str, user_key: str): + self.contract_id = contract_id + self.user_key = user_key + + async def _execute_procedure(self, procedure_name: str, parameters=None): + source_keypair = Keypair.from_secret(self.user_key) + + soroban_server = SorobanServer('https://soroban-testnet.stellar.org') + + source_account = soroban_server.load_account(source_keypair.public_key) + + built_transaction = ( + TransactionBuilder( + source_account=source_account, + base_fee=100, + network_passphrase=Network.TESTNET_NETWORK_PASSPHRASE, + ) + .append_invoke_contract_function_op( + contract_id=self.contract_id, + function_name=procedure_name, + parameters=parameters, + ) + .set_timeout(30) + .build() + ) + + try: + prepared_transaction = soroban_server.prepare_transaction(built_transaction) + except PrepareTransactionException as e: + print(f"Exception preparing transaction: {e}\n{e.simulate_transaction_response.error}") + raise e + + prepared_transaction.sign(source_keypair) + + send_response = soroban_server.send_transaction(prepared_transaction) + + if send_response.status != SendTransactionStatus.PENDING: + raise Exception("sending transaction failed") + + while True: + get_response = soroban_server.get_transaction(send_response.hash) + if get_response.status != GetTransactionStatus.NOT_FOUND: + break + await asyncio.sleep(2) + + print(f"get_transaction response: {get_response}") + + if get_response.status == GetTransactionStatus.SUCCESS: + assert get_response.result_meta_xdr is not None + + transaction_meta = stellar_xdr.TransactionMeta.from_xdr( + get_response.result_meta_xdr + ) + return_value = transaction_meta.v3.soroban_meta.return_value + output = translate_soroban_value(return_value) + return output + else: + print(f"Transaction failed: {get_response.result_xdr}") + + +def translate_soroban_value(val: SCVal) -> int or str or bool or list[int or str or bool] or None: + def sanitize(k: str) -> str: + return k.lstrip('b\'').rstrip('\'') + + type_handlers = { + SCValType.SCV_U32: lambda v: v.u32.uint32, + SCValType.SCV_I32: lambda v: v.i32.int32, + SCValType.SCV_U64: lambda v: v.u64.uint64, + SCValType.SCV_I64: lambda v: v.i64.int64, + SCValType.SCV_BOOL: lambda v: v.bool.boolean, + SCValType.SCV_STRING: lambda v: sanitize(str(v.str.sc_string)), + SCValType.SCV_SYMBOL: lambda v: sanitize(str(v.sym.sc_symbol)), + SCValType.SCV_BYTES: lambda v: bytes(v.bytes.sc_bytes), + SCValType.SCV_VEC: lambda v: [translate_soroban_value(item) for item in v.vec.sc_vec], + SCValType.SCV_MAP: lambda v: {translate_soroban_value(item.key): translate_soroban_value(item.val) for item in v.map.sc_map}, + } + + if val.type == SCValType.SCV_VOID: + return None + + handler = type_handlers.get(val.type) + if handler: + return handler(val) + else: + raise ValueError(f"Unsupported SCVal type: {val.type}") diff --git a/app/contracts/issue.py b/app/contracts/issue.py new file mode 100644 index 0000000..15d0121 --- /dev/null +++ b/app/contracts/issue.py @@ -0,0 +1,42 @@ +from uuid import uuid4 + +from stellar_sdk.xdr import SCString, SCVal, SCBytes, SCVec, SCValType +from typing_extensions import TypeVar + +from app.contracts.contract import SmartContract +from app.schemas.issue import IssuePost + +T = TypeVar("T") + +class IssueContract(SmartContract): + def __init__(self, contract_id, user_key): + super().__init__(contract_id, user_key) + + async def list_issues(self): + issues = await self._execute_procedure("list_issues") + return [IssuePost(**issue) for issue in issues] + + async def add_issue(self, title: str, paragraphs: list[str], telegram_handle: str): + issue_id = uuid4().bytes + print(f' => {telegram_handle}') + await self._execute_procedure("add_issue", [ + SCVal(type=SCValType.SCV_BYTES, bytes=SCBytes(issue_id)), + SCVal(type=SCValType.SCV_STRING, str=SCString(title.encode('utf-8'))), + SCVal(type=SCValType.SCV_VEC, vec=SCVec([SCVal(SCValType.SCV_STRING, str=SCString(paragraph.encode('utf-8'))) for paragraph in paragraphs])), + SCVal(type=SCValType.SCV_STRING, str=SCString(telegram_handle.encode('utf-8'))), + ]) + + async def get_paragraphs(self, issue_id: bytes): + return await self._execute_procedure("get_paragraphs_for_issue", [ + SCVal(type=SCValType.SCV_BYTES, bytes=SCBytes(issue_id)) + ]) + + async def increase_vote(self, issue_id: bytes): + await self._execute_procedure("increase_positive_vote", [ + SCVal(type=SCValType.SCV_BYTES, bytes=SCBytes(issue_id)) + ]) + + async def decrease_vote(self, issue_id: bytes): + await self._execute_procedure("increase_negative_vote", [ + SCVal(type=SCValType.SCV_BYTES, bytes=SCBytes(issue_id)) + ]) diff --git a/app/core/__pycache__/config.cpython-312.pyc b/app/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..f2982cd Binary files /dev/null and b/app/core/__pycache__/config.cpython-312.pyc differ diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..f726028 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,15 @@ +class Settings: + PROJECT_NAME = 'Puff Pastry' + VERSION = '0.1.0' + DESCRIPTION = 'A DAO platform on the Stellar network' + ALLOWED_ORIGINS = ('0.0.0.0', '127.0.0.1') + API_V1_STR = '/api/v1' + + AUTH_TIMEOUT = 10800 + + WALLET_ID = 'SANMQAR5UXHAQNLXGYIDZDIPFH3DADD3UCS6EFXN52QBI4AK6YEDWDJ3' + CONTRACT_ID = 'CAZNJSDVIQSGMYBLUINX3CS4WC4N6ZEDZEK64P45XDXHXPL4R7OYM4HB' + + DATABASE_URL = 'postgresql+asyncpg://mein:MxS2p37ViXtXikeb@localhost:5432/puffpastry' + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/__pycache__/user.cpython-312.pyc b/app/crud/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..30048ee Binary files /dev/null and b/app/crud/__pycache__/user.cpython-312.pyc differ diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..7e1caac --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from sqlalchemy import insert, select + +from app.models.user import user + + +async def add_user(session: AsyncSession, user_data: dict): + stmt = insert(user).values(**user_data) + await session.execute(stmt) + +async def get_user(session: AsyncSession, session_id: str): + stmt = select(user).where(user.c.session_id == session_id) + result = await session.execute(stmt) + if result: + return result.first() + return None diff --git a/app/db/__pycache__/base.cpython-312.pyc b/app/db/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..3d954b2 Binary files /dev/null and b/app/db/__pycache__/base.cpython-312.pyc differ diff --git a/app/db/__pycache__/session.cpython-312.pyc b/app/db/__pycache__/session.cpython-312.pyc new file mode 100644 index 0000000..242fd4e Binary files /dev/null and b/app/db/__pycache__/session.cpython-312.pyc differ diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..a5ed424 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20 +) + +# Create a custom session class +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + +# Create a base class for declarative models +Base = declarative_base() diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..e5afa3f --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,17 @@ +from typing import AsyncGenerator +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.base import AsyncSessionLocal + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except SQLAlchemyError as e: + await session.rollback() + raise + finally: + await session.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1fb0480 --- /dev/null +++ b/app/main.py @@ -0,0 +1,40 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.config import settings +from app.api.v1.api import api_router +from app.db.base import engine, Base + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +@asynccontextmanager +async def lifespan(application: FastAPI): + await init_db() + yield + +app = FastAPI( + title=settings.PROJECT_NAME, + version=settings.VERSION, + description=settings.DESCRIPTION, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + lifespan=lifespan, +) + +# Set up CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(router=api_router, prefix=settings.API_V1_STR) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/app/models/__pycache__/user.cpython-312.pyc b/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..27b6524 Binary files /dev/null and b/app/models/__pycache__/user.cpython-312.pyc differ diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..d09c8a6 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,17 @@ +from datetime import datetime, UTC + +from sqlalchemy import Column, Integer, String, Table + +from app.db.base import Base + +user = Table( + "sessions", + Base.metadata, + Column("id", Integer, primary_key=True), + Column("session_id", String, nullable=False), + Column("auth_date", Integer, nullable=False), + Column("username", String, nullable=False), + Column("first_name", String, nullable=False), + Column("last_name", String, nullable=False), + Column("photo_url", String, nullable=False) +) diff --git a/app/schemas/__pycache__/issue.cpython-312.pyc b/app/schemas/__pycache__/issue.cpython-312.pyc new file mode 100644 index 0000000..7114793 Binary files /dev/null and b/app/schemas/__pycache__/issue.cpython-312.pyc differ diff --git a/app/schemas/__pycache__/user.cpython-312.pyc b/app/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..5828fc5 Binary files /dev/null and b/app/schemas/__pycache__/user.cpython-312.pyc differ diff --git a/app/schemas/issue.py b/app/schemas/issue.py new file mode 100644 index 0000000..60939b2 --- /dev/null +++ b/app/schemas/issue.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, computed_field, PrivateAttr + + +class IssuePost(BaseModel): + _id: bytes = PrivateAttr() + title: str + summary: str + paragraph_count: int + positive_votes: int + negative_votes: int + telegram_handle: str + created_at: int + + def __init__(self, id: bytes, **data): + super().__init__(**data) + self._id = id + + @computed_field(return_type=str) + @property + def id(self) -> str: + return self._id.hex() + + @id.setter + def id(self, value: bytes) -> None: + self._id = value + + +class AddIssueRequest(BaseModel): + session_id: str + title: str + paragraphs: list[str] + + +class AddIssueResponse(BaseModel): + result: bool + + +class VoteIssueRequest(BaseModel): + issue_id: str + increase: bool + decrease: bool diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..446b6c9 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class User(BaseModel): + session_id: str + auth_date: int + username: str + first_name: str + last_name: str + photo_url: str + + +class AuthenticationRequest(BaseModel): + id: int + auth_date: int + username: str + first_name: str + last_name: str + photo_url: str