Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/domains/nfts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
116 changes: 116 additions & 0 deletions app/domains/nfts/nft_buy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException
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

from app.domains.auth.models import WalletAuth
from app.domains.auth.router import get_current_wallet_auth

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: WalletAuth = Depends(get_current_wallet_auth),
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,
}
5 changes: 5 additions & 0 deletions app/domains/nfts/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
5 changes: 5 additions & 0 deletions app/domains/nfts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 49 additions & 11 deletions app/domains/nfts/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}


Expand All @@ -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 # 구매 오퍼
}
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
5 changes: 3 additions & 2 deletions app/shared/xrpl.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
from typing import Optional

import xrpl
from fastapi import HTTPException
from starlette import status
from xrpl.clients import JsonRpcClient
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)
Expand Down