| @@ -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 | |||||
| @@ -0,0 +1,12 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="DataSourceManagerImpl" format="xml" multifile-model="true"> | |||||
| <data-source source="LOCAL" name="postgres@localhost" uuid="94d2e7f4-35ff-4539-829f-43905c948f13"> | |||||
| <driver-ref>postgresql</driver-ref> | |||||
| <synchronize>true</synchronize> | |||||
| <jdbc-driver>org.postgresql.Driver</jdbc-driver> | |||||
| <jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url> | |||||
| <working-dir>$ProjectFileDir$</working-dir> | |||||
| </data-source> | |||||
| </component> | |||||
| </project> | |||||
| @@ -0,0 +1,6 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="HttpClientEndpointsTabState"> | |||||
| <option name="requestToText" value="<CachedHttpClientTabRequests> <entry key="63d726ce0c4905b3c3271c9a6717db45e9bccbbb" value="###&#10;GET http://localhost:8000/" /> </CachedHttpClientTabRequests>" /> | |||||
| </component> | |||||
| </project> | |||||
| @@ -0,0 +1,6 @@ | |||||
| <component name="InspectionProjectProfileManager"> | |||||
| <profile version="1.0"> | |||||
| <option name="myName" value="Project Default" /> | |||||
| <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" /> | |||||
| </profile> | |||||
| </component> | |||||
| @@ -0,0 +1,6 @@ | |||||
| <component name="InspectionProjectProfileManager"> | |||||
| <settings> | |||||
| <option name="USE_PROJECT_PROFILE" value="false" /> | |||||
| <version value="1.0" /> | |||||
| </settings> | |||||
| </component> | |||||
| @@ -0,0 +1,7 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="Black"> | |||||
| <option name="sdkName" value="Python 3.12 (puffpastry)" /> | |||||
| </component> | |||||
| <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (puffpastry)" project-jdk-type="Python SDK" /> | |||||
| </project> | |||||
| @@ -0,0 +1,8 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="ProjectModuleManager"> | |||||
| <modules> | |||||
| <module fileurl="file://$PROJECT_DIR$/.idea/puffpastry.iml" filepath="$PROJECT_DIR$/.idea/puffpastry.iml" /> | |||||
| </modules> | |||||
| </component> | |||||
| </project> | |||||
| @@ -0,0 +1,10 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <module type="PYTHON_MODULE" version="4"> | |||||
| <component name="NewModuleRootManager"> | |||||
| <content url="file://$MODULE_DIR$"> | |||||
| <excludeFolder url="file://$MODULE_DIR$/.venv" /> | |||||
| </content> | |||||
| <orderEntry type="inheritedJdk" /> | |||||
| <orderEntry type="sourceFolder" forTests="false" /> | |||||
| </component> | |||||
| </module> | |||||
| @@ -0,0 +1,6 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="VcsDirectoryMappings"> | |||||
| <mapping directory="$PROJECT_DIR$" vcs="Git" /> | |||||
| </component> | |||||
| </project> | |||||
| @@ -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"]) | |||||
| @@ -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 | |||||
| @@ -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} | |||||
| @@ -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}") | |||||
| @@ -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)) | |||||
| ]) | |||||
| @@ -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() | |||||
| @@ -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 | |||||
| @@ -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() | |||||
| @@ -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() | |||||
| @@ -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) | |||||
| @@ -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) | |||||
| ) | |||||
| @@ -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 | |||||
| @@ -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 | |||||