@@ -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 |