From 6bfec08e558493ca76d12c1761c88fec8fa01ace Mon Sep 17 00:00:00 2001 From: SiwonChoi25 Date: Sun, 21 Sep 2025 04:40:58 +0900 Subject: [PATCH 1/2] nft buy --- app/domains/nfts/models.py | 10 ++-- app/domains/nfts/nft_buy.py | 113 +++++++++++++++++++++++++++++++++++ app/domains/nfts/router.py | 5 ++ app/domains/nfts/schemas.py | 5 ++ app/domains/nfts/services.py | 60 +++++++++++++++---- app/main.py | 2 + app/shared/xrpl.py | 5 +- 7 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 app/domains/nfts/nft_buy.py diff --git a/app/domains/nfts/models.py b/app/domains/nfts/models.py index f3ca743..24dc35d 100644 --- a/app/domains/nfts/models.py +++ b/app/domains/nfts/models.py @@ -12,12 +12,12 @@ class Artwork(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String(255), nullable=False) description = Column(Text, nullable=False) - size = Column(String(50), nullable=False) # 예: "2x2", "3x3" + size = Column(String(50), nullable=False) # 예: "2x2", "3x3" price_usd = Column(Integer, nullable=False) - grid_n = Column(Integer, nullable=False) # 조각 분할 크기 - image_url = Column(String(500), nullable=False) # S3/IPFS 저장 URL - metadata_uri_base = Column(String(500), nullable=False) # ex) ipfs://cid/meta.json - artist_address = Column(String(128), nullable=False) # 작가 XRPL 주소 + grid_n = Column(Integer, nullable=False) # 조각 분할 크기 + image_url = Column(String(500), nullable=False) # S3/IPFS 저장 URL + metadata_uri_base = Column(String(500), nullable=False) # ex) ipfs://cid/meta.json + artist_address = Column(String(128), nullable=False) # 작가 XRPL 주소 created_at = Column(DateTime, default=datetime.utcnow) nfts = relationship("NFT", back_populates="artwork") diff --git a/app/domains/nfts/nft_buy.py b/app/domains/nfts/nft_buy.py new file mode 100644 index 0000000..4633751 --- /dev/null +++ b/app/domains/nfts/nft_buy.py @@ -0,0 +1,113 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from xrpl.clients import JsonRpcClient +from xrpl.models.requests import Tx, LedgerEntry + +from app.core.config import settings +from app.shared.database.connection import get_db +from app.domains.nfts.models import NFT + +router = APIRouter(prefix="/nfts", tags=["NFT Purchase"]) + + +# ---------------------------- +# Utils +# ---------------------------- +def build_accept_sell_offer_tx_json(buyer_address: str, sell_offer_id: str) -> dict: + """구매자가 특정 Sell Offer를 수락하기 위한 트랜잭션 JSON 생성""" + return { + "TransactionType": "NFTokenAcceptOffer", + "Account": buyer_address, + "NFTokenSellOffer": sell_offer_id, + } + + +def _get_offer_owner(client: JsonRpcClient, offer_id: str) -> str | None: + """LedgerEntry로부터 오퍼의 Owner 주소 확인""" + resp = client.request(LedgerEntry(index=offer_id, ledger_index="validated")) + node = (resp.result or {}).get("node") or {} + if node.get("LedgerEntryType") == "NFTokenOffer": + return node.get("Owner") + return None + + +# ---------------------------- +# API Endpoints +# ---------------------------- +@router.get("/{nft_id}/sell-accept/txjson") +def get_sell_accept_txjson( + nft_id: int, + buyer_address: str = Query(..., description="구매자 XRPL 주소"), + db: Session = Depends(get_db), +): + """ + 구매자가 직접 서명할 수 있도록 NFTokenAcceptOffer 트랜잭션 JSON 생성. + """ + nft = db.query(NFT).filter(NFT.id == nft_id).first() + if not nft or not nft.nftoken_id: + raise HTTPException(404, "NFT not found or not minted") + + sell_offer_id = (nft.extra or {}).get("sell_offer_id") + if not sell_offer_id: + raise HTTPException(400, "Sell offer not recorded") + + tx_json = build_accept_sell_offer_tx_json(buyer_address, sell_offer_id) + return { + "tx_json": tx_json, + "sell_offer_id": sell_offer_id, + "nftoken_id": nft.nftoken_id, + } + + +@router.post("/{nft_id}/sell-accept/confirm") +def confirm_sell_accept( + nft_id: int, + tx_hash: str, + buyer_address: str, + db: Session = Depends(get_db), +): + """ + 구매자가 Sell Offer를 Accept한 뒤 tx_hash를 받아 DB에 반영. + """ + nft = db.query(NFT).filter(NFT.id == nft_id).first() + if not nft: + raise HTTPException(404, "NFT not found") + + sell_offer_id = (nft.extra or {}).get("sell_offer_id") + if not sell_offer_id: + raise HTTPException(400, "Sell offer not recorded") + + client = JsonRpcClient(settings.xrpl_rpc_url) + + # 트랜잭션 검증 + txr = client.request(Tx(transaction=tx_hash)).result + if not txr.get("validated"): + raise HTTPException(400, "Transaction not validated yet") + + # (선택) 오퍼 소유자 확인 + offer_owner = _get_offer_owner(client, sell_offer_id) + if not offer_owner: + # 오퍼가 체결되어 이미 소멸했을 수 있음 → 스킵 + pass + + # DB 업데이트 + extra = (nft.extra or {}).copy() + extra.update( + { + "sell_accept_tx_hash": tx_hash, + "sold_via": "direct_accept", + } + ) + nft.owner_address = buyer_address + nft.status = "sold" + nft.extra = extra + db.add(nft) + db.commit() + + return { + "ok": True, + "nft_id": nft_id, + "new_owner": buyer_address, + "tx_hash": tx_hash, + } diff --git a/app/domains/nfts/router.py b/app/domains/nfts/router.py index 6a860f2..8519e0d 100644 --- a/app/domains/nfts/router.py +++ b/app/domains/nfts/router.py @@ -8,6 +8,11 @@ from .schemas import RegisterMintOut, VerifyIn, VerifyOut from .services import register_to_ipfs_and_mint, verify_tx + +from fastapi.security import HTTPBearer + +security = HTTPBearer() + logger = logging.getLogger(__name__) router = APIRouter(prefix="/nfts", tags=["NFTs"]) diff --git a/app/domains/nfts/schemas.py b/app/domains/nfts/schemas.py index 7f687cf..3345e74 100644 --- a/app/domains/nfts/schemas.py +++ b/app/domains/nfts/schemas.py @@ -26,3 +26,8 @@ class VerifyIn(BaseModel): class VerifyOut(BaseModel): validated: bool tx_json: Dict[str, Any] | None = None + + +class BrokerRequest(BaseModel): + sell_offer_id: str + buy_offer_id: str diff --git a/app/domains/nfts/services.py b/app/domains/nfts/services.py index 11a05bf..c1dacbe 100644 --- a/app/domains/nfts/services.py +++ b/app/domains/nfts/services.py @@ -95,6 +95,14 @@ def _create_zero_amount_gift_offer( return o_resp.result +def _build_accept_tx_json(artist_address: str, offer_id: str) -> dict: + return { + "TransactionType": "NFTokenAcceptOffer", + "Account": artist_address, + "NFTokenSellOffer": offer_id, + } + + def _sync_xrpl_batch_mint( db: Session, artwork_id: int, @@ -379,6 +387,23 @@ async def register_to_ipfs_and_mint( else ("partial" if mint_result["minted"] > 0 else "failed") ) + # offer_result 기반으로 accept tx_json 배열 생성 + accept_txjsons = [] + for oid in offer_result["offer_ids"]: + if oid: # None 체크 + accept_txjsons.append(_build_accept_tx_json(artist_address, oid)) + + # sell_txjsons 배열 생성 + # sell_txjsons = [] + # rows = db.query(NFT).filter(NFT.artwork_id == artwork.id).all() + # for r in rows: + # if r.nftoken_id: + # sell_txjsons.append(_build_sell_offer_tx_json( + # artist_address, # 소유자가 작가 + # r.nftoken_id, + # nft_price_usd * 1_000_000 # drops 단위 + # )) + return { "artwork_id": artwork.id, "artist_address": artist_address, @@ -387,18 +412,20 @@ async def register_to_ipfs_and_mint( "metadata_cid": metadata_cid, "metadata_uri_base": metadata_uri_base, "metadata_http_url": metadata_http_url, - "minted": mint_result["minted"], - "failed": mint_result["failed"], - "tx_hashes": mint_result["tx_hashes"], - "nftoken_ids": mint_result["nftoken_ids"], - "nft_price_usd": mint_result["nft_price_usd"], + # "minted": mint_result["minted"], + # "failed": mint_result["failed"], + # "tx_hashes": mint_result["tx_hashes"], + # "nftoken_ids": mint_result["nftoken_ids"], + # "nft_price_usd": mint_result["nft_price_usd"], + # "status": status, + # "offers_created": offer_result["offers_created"], + # "offers_total_considered": offer_result["offers_total_considered"], + # "offer_ids": offer_result["offer_ids"], + # "offer_tx_hashes": offer_result["offer_tx_hashes"], + # "offer_failed": offer_result["failed"], "status": status, - "offers_created": offer_result["offers_created"], - "offers_total_considered": offer_result["offers_total_considered"], - "offer_ids": offer_result["offer_ids"], - "offer_tx_hashes": offer_result["offer_tx_hashes"], - "offer_failed": offer_result["failed"], - "status": status + "accept_txjsons": accept_txjsons, # ✅ 프론트에서 지갑으로 넘길 배열 + # "sell_txjsons": sell_txjsons, } @@ -409,3 +436,14 @@ def verify_tx(tx_hash: str) -> Dict[str, Any]: r = resp.result validated = bool(r.get("validated")) return {"validated": validated, "tx_json": r if validated else None} + + +def build_buy_offer_txjson(buyer_address: str, nftoken_id: str, amount_drops: int, seller_address: str) -> dict: + return { + "TransactionType": "NFTokenCreateOffer", + "Account": buyer_address, + "NFTokenID": nftoken_id, + "Amount": str(amount_drops), # drops 단위 + "Owner": seller_address, # 판매자(작가) 주소 + "Flags": 0 # 구매 오퍼 + } diff --git a/app/main.py b/app/main.py index 41008fc..ccc497c 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from app.domains.auth.router import router as auth_router from app.domains.gallery.router import router as gallery_router from app.domains.nfts.router import router as nfts_router +from app.domains.nfts.nft_buy import router as nft_buy_router from app.shared.database.connection import Base, engine, get_db # Create database tables @@ -42,6 +43,7 @@ def create_app() -> FastAPI: application.include_router(artwork_router, prefix="/api/v1") application.include_router(gallery_router, prefix="/api/v1") application.include_router(nfts_router, prefix="/api/v1") + application.include_router(nft_buy_router, prefix="/api/v1") @application.get("/") async def root() -> dict[str, str]: diff --git a/app/shared/xrpl.py b/app/shared/xrpl.py index a29feea..d72f663 100644 --- a/app/shared/xrpl.py +++ b/app/shared/xrpl.py @@ -1,5 +1,4 @@ from typing import Optional - import xrpl from fastapi import HTTPException from starlette import status @@ -7,10 +6,12 @@ from xrpl.models import PermissionedDomainSet from xrpl.models.transactions.deposit_preauth import Credential from xrpl.wallet import Wallet - from app.core.config import settings +DEVNET_URL = getattr(settings, "xrpl_rpc_url", "https://s.devnet.rippletest.net:51234") + + class XRPLService: def __init__(self): self.client = JsonRpcClient(settings.xrpl_rpc_url) From 1f7f4fe15dae57bde5b5552cf3caa6c3d1a056c2 Mon Sep 17 00:00:00 2001 From: SiwonChoi25 Date: Sun, 21 Sep 2025 05:00:12 +0900 Subject: [PATCH 2/2] nft buy json --- app/domains/nfts/nft_buy.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/domains/nfts/nft_buy.py b/app/domains/nfts/nft_buy.py index 4633751..aced2e2 100644 --- a/app/domains/nfts/nft_buy.py +++ b/app/domains/nfts/nft_buy.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from xrpl.clients import JsonRpcClient @@ -8,6 +8,9 @@ from app.shared.database.connection import get_db from app.domains.nfts.models import NFT +from app.domains.auth.models import WalletAuth +from app.domains.auth.router import get_current_wallet_auth + router = APIRouter(prefix="/nfts", tags=["NFT Purchase"]) @@ -38,8 +41,8 @@ def _get_offer_owner(client: JsonRpcClient, offer_id: str) -> str | None: @router.get("/{nft_id}/sell-accept/txjson") def get_sell_accept_txjson( nft_id: int, - buyer_address: str = Query(..., description="구매자 XRPL 주소"), - db: Session = Depends(get_db), + buyer_address: WalletAuth = Depends(get_current_wallet_auth), + db: Session = Depends(get_db) ): """ 구매자가 직접 서명할 수 있도록 NFTokenAcceptOffer 트랜잭션 JSON 생성.