diff --git a/contracts/market/Market.go b/contracts/market/Market.go new file mode 100644 index 0000000..615d2cf --- /dev/null +++ b/contracts/market/Market.go @@ -0,0 +1,118 @@ +package contract + +import ( + "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/iterator" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/interop/std" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" +) + +const ( + ownerKey = "o" + tokenKey = "t" + nftKey = "n" + sellPrefix = "forSale_" +) + +// MarketDeployData структура для _deploy (пример) +type MarketDeployData struct { + Admin interop.Hash160 + Token interop.Hash160 + Nft interop.Hash160 +} + +func _deploy(data any, isUpdate bool) { + if isUpdate { + return + } + + initData := data.(MarketDeployData) + admin := initData.Admin + if len(admin) != 20 { + panic("invalid admin") + } + + ctx := storage.GetContext() + storage.Put(ctx, []byte(ownerKey), admin) + storage.Put(ctx, []byte(tokenKey), initData.Token) + storage.Put(ctx, []byte(nftKey), initData.Nft) +} + +// OnNEP11Payment вызывается при получении NFT +func OnNEP11Payment(from interop.Hash160, amount int, tokenId []byte, data any) { + if amount != 1 { + panic("Must be non-fungible (amount=1)") + } + ctx := storage.GetContext() + callingHash := runtime.GetCallingScriptHash() + nftHash := storage.Get(ctx, []byte(nftKey)).(interop.Hash160) + if !std.Equals(callingHash, nftHash) { + panic("NFT not recognized") + } + + // Выставляем NFT на продажу, если владелец хочет + // Цена берется из свойств NFT (fields: price) + saleKey := append([]byte(sellPrefix), tokenId...) + storage.Put(ctx, saleKey, []byte{1}) +} + +// OnNEP17Payment вызывается при получении токенов (оплата за NFT) +func OnNEP17Payment(from interop.Hash160, amount int, data any) { + ctx := storage.GetContext() + callingHash := runtime.GetCallingScriptHash() + myToken := storage.Get(ctx, []byte(tokenKey)).(interop.Hash160) + if !std.Equals(callingHash, myToken) { + panic("invalid token") + } + + tokenIdBytes, ok := data.([]byte) + if !ok { + panic("no tokenId in data") + } + tokenId := string(tokenIdBytes) + + saleKey := append([]byte(sellPrefix), tokenIdBytes...) + if storage.Get(ctx, saleKey) == nil { + panic("NFT not on sale") + } + + // Узнаем price из NFT + nftHash := storage.Get(ctx, []byte(nftKey)).(interop.Hash160) + props := contract.Call(nftHash, "properties", contract.ReadStates, tokenId).(map[string]any) + // price хранится как string => нужно конвертировать + price := std.Atoi(props["price"].(string)) + if amount < price { + panic("insufficient payment") + } + + // снимаем с продажи + storage.Delete(ctx, saleKey) + // передаем NFT покупателю + contract.Call(nftHash, "transfer", contract.All, from, tokenId, nil) +} + +// List вернет массив token_id, которые сейчас на продаже +func List() []string { + ctx := storage.GetContext() + iter := storage.Find(ctx, []byte(sellPrefix), storage.KeysOnly) + var result []string + for iterator.Next(iter) { + key := iterator.Key(iter).([]byte) + tokenId := key[len(sellPrefix):] + result = append(result, string(tokenId)) + } + return result +} + +// TransferTokens позволяет владельцу контракта забрать токены с контракта. +func TransferTokens(to interop.Hash160, amount int) { + ctx := storage.GetContext() + owner := storage.Get(ctx, []byte(ownerKey)).(interop.Hash160) + if !runtime.CheckWitness(owner) { + panic("not an owner") + } + myToken := storage.Get(ctx, []byte(tokenKey)).(interop.Hash160) + contract.Call(myToken, "transfer", contract.All, runtime.GetExecutingScriptHash(), to, amount, nil) +} diff --git a/contracts/nep11/OnlyFansNFT.py b/contracts/nep11/OnlyFansNFT.py new file mode 100644 index 0000000..625dedf --- /dev/null +++ b/contracts/nep11/OnlyFansNFT.py @@ -0,0 +1,135 @@ +from typing import Any, Dict, List +from boa3.builtin import public +from boa3.builtin.contract import abort +from boa3.builtin.interop.runtime import check_witness +from boa3.builtin.interop.storage import get, put, delete, find, FindOptions +from boa3.builtin.type import UInt160 + +TOKEN_PREFIX = b'token_' +OWNER_PREFIX = b'owner_' +SUPPLY_KEY = b'totalSupply' +ADMIN_KEY = b'admin' + +@public +def symbol() -> str: + return "OFNFT" # OnlyFans NFT + +@public +def name() -> str: + return "Web3OnlyFansNFT" + +@public +def totalSupply() -> int: + return get(SUPPLY_KEY).to_int() + +@public +def balanceOf(owner: UInt160) -> int: + return len(_getTokensOf(owner)) + +@public +def ownerOf(token_id: str) -> UInt160: + token_data = get(TOKEN_PREFIX + token_id.encode()) + if not token_data: + abort() + info = _deserialize_token(token_data) + return info['owner'] + +@public +def tokensOf(owner: UInt160) -> List[str]: + return _getTokensOf(owner) + +@public +def properties(token_id: str) -> Dict[str, Any]: + token_data = get(TOKEN_PREFIX + token_id.encode()) + if not token_data: + abort() + return _deserialize_token(token_data) + +@public +def transfer(to: UInt160, token_id: str, data: Any) -> bool: + if len(to) != 20: + abort() + + token_info = _readToken(token_id) + from_addr = token_info['owner'] + + if not check_witness(from_addr): + abort() + + if from_addr == to: + return True + + _removeTokenOf(from_addr, token_id) + _addTokenOf(to, token_id) + + token_info['owner'] = to + put(TOKEN_PREFIX + token_id.encode(), _serialize_token(token_info)) + return True + +@public +def mint(token_id: str, name: str, blurred_ref: str, full_ref: str, price: int) -> bool: + admin = get(ADMIN_KEY) + if not admin or len(admin) != 20: + abort() + + if not check_witness(admin.to_uint160()): + abort() + + existing = get(TOKEN_PREFIX + token_id.encode()) + if existing: + abort() # уже есть + + owner = admin.to_uint160() + + info = { + "owner": owner, + "name": name, + "blurred_ref": blurred_ref, + "full_ref": full_ref, + "price": price + } + put(TOKEN_PREFIX + token_id.encode(), _serialize_token(info)) + + current_supply = totalSupply() + put(SUPPLY_KEY, current_supply + 1) + _addTokenOf(owner, token_id) + + return True + +@public +def deploy(admin: UInt160): + if totalSupply() != 0: + return + put(ADMIN_KEY, admin) + +def _readToken(token_id: str) -> Dict[str, Any]: + token_data = get(TOKEN_PREFIX + token_id.encode()) + if not token_data: + abort() + return _deserialize_token(token_data) + +def _getTokensOf(owner: UInt160) -> List[str]: + prefix = OWNER_PREFIX + owner + result: List[str] = [] + iterator = find(prefix, options=FindOptions.KeysOnly) + while iterator.next(): + key = iterator.value + token_id = key[len(prefix):].decode() + result.append(token_id) + return result + +def _addTokenOf(owner: UInt160, token_id: str): + owner_key = OWNER_PREFIX + owner + token_id.encode() + put(owner_key, b'1') + +def _removeTokenOf(owner: UInt160, token_id: str): + owner_key = OWNER_PREFIX + owner + token_id.encode() + delete(owner_key) + +def _serialize_token(token_info: Dict[str, Any]) -> bytes: + from boa3.builtin import serialize + return serialize(token_info) + +def _deserialize_token(data: bytes) -> Dict[str, Any]: + from boa3.builtin import deserialize + return deserialize(data) diff --git a/contracts/nep17/MyNeoToken.py b/contracts/nep17/MyNeoToken.py new file mode 100644 index 0000000..0e3c26f --- /dev/null +++ b/contracts/nep17/MyNeoToken.py @@ -0,0 +1,77 @@ +from typing import Any +from boa3.builtin import public +from boa3.builtin.contract import Nep17TransferEvent, abort +from boa3.builtin.interop.contract import call_contract +from boa3.builtin.interop.runtime import calling_script_hash, check_witness, executing_script_hash +from boa3.builtin.interop.storage import get, put +from boa3.builtin.type import UInt160 + +# ------------------------------------------------------ +# Конфигурация токена +# ------------------------------------------------------ +TOKEN_SYMBOL = 'MYNEO' +TOKEN_DECIMALS = 0 + +TOTAL_SUPPLY_KEY = b'totalSupply' +BALANCE_PREFIX = b'balance_' +ADMIN_KEY = b'admin' # владелец смарт-контракта + +on_transfer = Nep17TransferEvent + +@public +def symbol() -> str: + return TOKEN_SYMBOL + +@public +def decimals() -> int: + return TOKEN_DECIMALS + +@public +def totalSupply() -> int: + return get(TOTAL_SUPPLY_KEY).to_int() + +@public +def balanceOf(account: UInt160) -> int: + return get(BALANCE_PREFIX + account).to_int() + +@public +def transfer(from_addr: UInt160, to_addr: UInt160, amount: int, data: Any) -> bool: + if amount < 0: + abort() + + if not check_witness(from_addr): + abort() + + if len(to_addr) != 20: + abort() + + from_balance = balanceOf(from_addr) + if from_balance < amount: + abort() + + if from_addr != to_addr and amount != 0: + put(BALANCE_PREFIX + from_addr, from_balance - amount) + to_balance = balanceOf(to_addr) + put(BALANCE_PREFIX + to_addr, to_balance + amount) + + on_transfer(from_addr, to_addr, amount) + + if data is not None: + # Вызываем onNEP17Payment, если у целевого контракта есть такой метод + call_contract(to_addr, 'onNEP17Payment', [from_addr, amount, data]) + + return True + +@public +def deploy(admin: UInt160): + """ + При первом деплое: устанавливаем админа и минтим 100_000_000 токенов. + """ + if totalSupply() != 0: + return # уже инициализировано + + put(ADMIN_KEY, admin) + minted = 100_000_000 + put(TOTAL_SUPPLY_KEY, minted) + put(BALANCE_PREFIX + admin, minted) + on_transfer(None, admin, minted) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a006cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +version: "3.8" +services: + + # NEO приватная нода для локальной разработки + neo-node: + image: nspccdev/neo-go + container_name: neo-node + command: [ + "node", + "--network=priv", + "--rpc", + "--rpc-port=20332", + "--wallet", "/neo-go/configs/wallet.json", + "--unlock", "address_in_wallet", + "--wallet-password", "pass" + ] + ports: + - "20332:20332" + volumes: + - ./neo-config:/neo-go/configs + + # FrostFS (локальный dev-кластер или удаленный endpoint) + # Для примера укажем некий самопальный образ, + # в реальности нужна полноценная конфигурация. + frostfs: + image: yourorg/frostfs-dev + container_name: frostfs + ports: + - "8080:8080" + environment: + - SOME_ENV=... + # ... + + # Сервис для загрузки в FrostFS + frostfs-uploader: + build: + context: ./services/frostfs-uploader + container_name: frostfs-uploader + environment: + - FROSTFS_ENDPOINT=grpcs://frostfs:8080 + - FROSTFS_PRIVKEY=... # dev key + - FROSTFS_CONTAINER_ID=... # dev container ID + ports: + - "8081:8081" + depends_on: + - frostfs + + # API-сервис + api: + build: + context: ./services/api + container_name: onlyfans-api + environment: + - NEO_RPC_ENDPOINT=http://neo-node:20332 + - NEO_WALLET_PATH=/app/wallets/dev.wallet.json + - NEO_WALLET_PASS=somepass + - NFT_CONTRACT_HASH=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + - MARKET_CONTRACT_HASH=cccccccccccccccccccccccccccccccccccccccc + volumes: + - ./services/api/wallets:/app/wallets + ports: + - "8080:8080" + depends_on: + - neo-node + + # Фронтенд + frontend: + build: + context: ./frontend + container_name: onlyfans-frontend + ports: + - "3000:3000" + depends_on: + - api + - frostfs-uploader diff --git a/services/api/internal/handlers/nft_handler.go b/services/api/internal/handlers/nft_handler.go new file mode 100644 index 0000000..93aa876 --- /dev/null +++ b/services/api/internal/handlers/nft_handler.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "encoding/json" + "math/big" + "net/http" + "os" + + "myproject/services/api/internal/neo" + + "github.com/nspcc-dev/neo-go/pkg/util" +) + +// Пример: /nft/mint?token_id=myPost1&blurred=OID1&full=OID2&price=100 +func HandleMintNFT(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient) { + // Получаем параметры + tokenId := r.URL.Query().Get("token_id") + blurred := r.URL.Query().Get("blurred") + full := r.URL.Query().Get("full") + priceStr := r.URL.Query().Get("price") + if tokenId == "" || blurred == "" || full == "" { + http.Error(w, "missing params", http.StatusBadRequest) + return + } + + // Конвертируем price + // (в примере NEP-17 без decimals, так что не умножаем на 10^дробность) + price := big.NewInt(0) + price.SetString(priceStr, 10) + + // Хэш NFT-контракта из ENV + nftHashStr := os.Getenv("NFT_CONTRACT_HASH") + nftHash, err := util.Uint160DecodeStringLE(nftHashStr) + if err != nil { + http.Error(w, "invalid NFT hash", http.StatusInternalServerError) + return + } + + // Вызываем метод "mint" + txHash, err := neoCli.Actor.Call(nftHash, "mint", []any{ + tokenId, // str + "My NFT", // name (можно отдельно передавать) + blurred, // blurred_ref + full, // full_ref + price.Int64(), + }, nil) + if err != nil { + http.Error(w, "mint call error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "tx_hash": txHash.StringBE(), + }) +} + +func HandleListMarket(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient) { + marketHashStr := os.Getenv("MARKET_CONTRACT_HASH") + marketHash, err := util.Uint160DecodeStringLE(marketHashStr) + if err != nil { + http.Error(w, "invalid MARKET hash", http.StatusInternalServerError) + return + } + + // Invoke без отправки транзакции (чтение из стейта) + res, err := neoCli.Actor.Reader().InvokeCall(marketHash, "List", []any{}, nil) + if err != nil { + http.Error(w, "List call error: "+err.Error(), http.StatusInternalServerError) + return + } + + // res.Value — stackitem + // Для простоты декодируем + tokenIDs, err := res.Stack[0].TryArray() + if err != nil { + http.Error(w, "decoding array error: "+err.Error(), http.StatusInternalServerError) + return + } + + list := []string{} + for _, t := range tokenIDs { + bs, err := t.TryBytes() + if err == nil { + list = append(list, string(bs)) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "onSale": list, + }) +} diff --git a/services/api/internal/neo/neo_client.go b/services/api/internal/neo/neo_client.go new file mode 100644 index 0000000..53c5c19 --- /dev/null +++ b/services/api/internal/neo/neo_client.go @@ -0,0 +1,47 @@ +package neo + +import ( + "context" + "fmt" + + "github.com/nspcc-dev/neo-go/pkg/rpcclient" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/wallet" +) + +type NeoClient struct { + Actor *actor.Actor + RPC *rpcclient.Client + Context context.Context +} + +func NewNeoClient(rpcEndpoint, walletPath, walletPass string) (*NeoClient, error) { + ctx := context.Background() + + rpcCli, err := rpcclient.New(ctx, rpcEndpoint, rpcclient.Options{}) + if err != nil { + return nil, fmt.Errorf("rpcclient new: %w", err) + } + + w, err := wallet.NewWalletFromFile(walletPath) + if err != nil { + return nil, fmt.Errorf("wallet load: %w", err) + } + + acc := w.GetAccount(w.GetChangeAddress()) + err = acc.Decrypt(walletPass, w.Scrypt) + if err != nil { + return nil, fmt.Errorf("wallet decrypt: %w", err) + } + + act, err := actor.NewSimple(rpcCli, acc) + if err != nil { + return nil, fmt.Errorf("actor new: %w", err) + } + + return &NeoClient{ + Actor: act, + RPC: rpcCli, + Context: ctx, + }, nil +} diff --git a/services/api/main.go b/services/api/main.go new file mode 100644 index 0000000..ce2be5d --- /dev/null +++ b/services/api/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "log" + "net/http" + "os" + + "myproject/services/api/internal/handlers" + "myproject/services/api/internal/neo" + + "github.com/gorilla/mux" +) + +func main() { + rpcEndpoint := os.Getenv("NEO_RPC_ENDPOINT") // "http://localhost:20332" + walletPath := os.Getenv("NEO_WALLET_PATH") + walletPass := os.Getenv("NEO_WALLET_PASS") + + // Инициализируем клиент NEO + neoCli, err := neo.NewNeoClient(rpcEndpoint, walletPath, walletPass) + if err != nil { + log.Fatalf("failed to init neo client: %v", err) + } + + r := mux.NewRouter() + r.HandleFunc("/nft/mint", func(w http.ResponseWriter, r *http.Request) { + handlers.HandleMintNFT(w, r, neoCli) + }).Methods("POST") + + r.HandleFunc("/nft/market-list", func(w http.ResponseWriter, r *http.Request) { + handlers.HandleListMarket(w, r, neoCli) + }).Methods("GET") + + // и т.д. + + log.Println("Starting API on :8080") + log.Fatal(http.ListenAndServe(":8080", r)) +} diff --git a/services/frostfs-uploader/main.go b/services/frostfs-uploader/main.go new file mode 100644 index 0000000..0f8695c --- /dev/null +++ b/services/frostfs-uploader/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/nspcc-dev/neofs-sdk-go/client" + "github.com/nspcc-dev/neofs-sdk-go/client/object" + "github.com/nspcc-dev/neofs-sdk-go/container" + // ... +) + +var ( + frostfsEndpoint = os.Getenv("FROSTFS_ENDPOINT") // типа "grpcs://localhost:8080" + frostfsPrivKey = os.Getenv("FROSTFS_PRIVKEY") + frostfsContainer = os.Getenv("FROSTFS_CONTAINER_ID") +) + +func main() { + // Инициализация + r := mux.NewRouter() + r.HandleFunc("/upload", handleUpload).Methods("POST") + + // HTTP-сервер + addr := ":8081" + log.Println("Starting FrostFS uploader on", addr) + log.Fatal(http.ListenAndServe(addr, r)) +} + +func handleUpload(w http.ResponseWriter, r *http.Request) { + // Пример: берем файл из multipart/form-data + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "file error: "+err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + // Читаем в []byte (для простоты, но лучше стримить) + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "read error: "+err.Error(), http.StatusInternalServerError) + return + } + + // Подключаемся к FrostFS + cli, err := client.New(client.WithDefaultPrivateKeyStr(frostfsPrivKey)) + if err != nil { + http.Error(w, "client init error: "+err.Error(), http.StatusInternalServerError) + return + } + defer cli.Close() + + err = cli.Dial(frostfsEndpoint) + if err != nil { + http.Error(w, "dial error: "+err.Error(), http.StatusInternalServerError) + return + } + + cntr, err := container.IDFromString(frostfsContainer) + if err != nil { + http.Error(w, "invalid container: "+err.Error(), http.StatusInternalServerError) + return + } + + oid, err := uploadFileToFrostFS(r.Context(), cli, cntr, data) + if err != nil { + http.Error(w, "frostfs upload error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"ok","object_id":"%s","filename":"%s"}`, oid, header.Filename) +} + +func uploadFileToFrostFS(ctx context.Context, cli *client.Client, cntr container.ID, data []byte) (string, error) { + obj := object.New() + obj.SetPayload(data) + + writer, err := cli.ObjectPutInit(ctx, cntr, obj) + if err != nil { + return "", err + } + err = writer.Write(data) + if err != nil { + return "", err + } + + oid, err := writer.Close() + if err != nil { + return "", err + } + return oid.String(), nil +}