Add files via upload

This commit is contained in:
NikitaR13 2025-01-23 15:29:43 +03:00 committed by GitHub
parent 537a825786
commit 49f5486782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1189 additions and 0 deletions

2
Web3-_--main/README.md Normal file
View file

@ -0,0 +1,2 @@
# Web3 (^_^)
Web3 OnlyFans — это децентрализованная платформа для монетизации контента, которая объединяет инфлюенсеров и подписчиков. Платформа использует технологии блокчейна для обеспечения прозрачности, безопасности, приватности и прямых финансовых взаимодействий без посредников.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,18 @@
# Dockerfile для API-сервиса
FROM golang:1.20 as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/api main.go
# Финальный образ
FROM alpine:3.17
WORKDIR /app
COPY --from=builder /app/bin/api /app/api
COPY config.yaml /app/config.yaml
EXPOSE 8080
ENTRYPOINT ["/app/api"]

View file

@ -0,0 +1,10 @@
listenAddr: ":8080"
logLevel: "debug"
neoRPC: "http://localhost:20332"
walletPath: "/app/wallets/dev.wallet.json"
walletPass: "dev-pass"
nftContractHash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
marketContractHash: "cccccccccccccccccccccccccccccccccccccccc"
tokenContractHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

View file

@ -0,0 +1,9 @@
module web3-onlyfans/services/api
go 1.20
require (
github.com/gorilla/mux v1.8.0
github.com/nspcc-dev/neo-go v0.99.0
# ...
)

View file

@ -0,0 +1,81 @@
package handlers
import (
"encoding/json"
"math/big"
"net/http"
"web3-onlyfans/services/api/internal/neo"
"github.com/nspcc-dev/neo-go/pkg/util"
"web3-onlyfans/services/api/internal/utils"
)
func MarketListHandler(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient, logger utils.Logger, cfg *utils.Config) {
marketHash, err := util.Uint160DecodeStringLE(cfg.MarketContractHash)
if err != nil {
http.Error(w, "invalid market hash", http.StatusInternalServerError)
return
}
invRes, err := neoCli.Actor.Reader().InvokeCall(marketHash, "List", nil, nil)
if err != nil {
logger.Errorf("invoke List error: %v", err)
http.Error(w, "market list error", http.StatusInternalServerError)
return
}
tokenList, err := utils.DecodeStringArray(invRes.Stack[0])
if err != nil {
logger.Errorf("decode array error: %v", err)
http.Error(w, "decode array error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"tokens": tokenList,
})
}
type MarketBuyRequest struct {
TokenID string `json:"token_id"`
Amount int64 `json:"amount"`
}
func MarketBuyHandler(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient, logger utils.Logger, cfg *utils.Config) {
var req MarketBuyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
tokenHash, err := util.Uint160DecodeStringLE(cfg.TokenContractHash)
if err != nil {
http.Error(w, "invalid token contract hash", http.StatusInternalServerError)
return
}
// Вызываем transfer NEP-17: (from=Actor, to=market, amount=req.Amount, data=req.TokenID)
dataBytes := []byte(req.TokenID)
amt := big.NewInt(req.Amount)
txHash, err := neoCli.Actor.Call(tokenHash, "transfer", []any{
neoCli.Actor.Sender(), // from
cfg.MarketContractHash, // to
amt, // amount
dataBytes, // data
}, nil)
if err != nil {
logger.Errorf("market buy transfer error: %v", err)
http.Error(w, "market buy error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"txHash": txHash.StringBE(),
"tokenID": req.TokenID,
"amount": req.Amount,
})
}

View file

@ -0,0 +1,86 @@
package handlers
import (
"encoding/json"
"github.com/nspcc-dev/neo-go/pkg/util"
"net/http"
"web3-onlyfans/services/api/internal/neo"
"web3-onlyfans/services/api/internal/utils"
)
type MintNFTRequest struct {
TokenID string `json:"token_id"`
Name string `json:"name"`
BlurredRef string `json:"blurred_ref"`
FullRef string `json:"full_ref"`
Price int `json:"price"`
}
func MintNFTHandler(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient, logger utils.Logger, cfg *utils.Config) {
var req MintNFTRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
nftHash, err := util.Uint160DecodeStringLE(cfg.NftContractHash)
if err != nil {
http.Error(w, "invalid NFT hash", http.StatusInternalServerError)
return
}
txHash, err := neoCli.Actor.Call(nftHash, "mint", []any{
req.TokenID,
req.Name,
req.BlurredRef,
req.FullRef,
req.Price,
}, nil)
if err != nil {
logger.Errorf("mint call error: %v", err)
http.Error(w, "mint call error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"txHash": txHash.StringBE(),
"tokenID": req.TokenID,
})
}
func NFTPropertiesHandler(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient, logger utils.Logger, cfg *utils.Config) {
tokenID := r.URL.Query().Get("token_id")
if tokenID == "" {
http.Error(w, "missing token_id", http.StatusBadRequest)
return
}
nftHash, err := util.Uint160DecodeStringLE(cfg.NftContractHash)
if err != nil {
http.Error(w, "invalid NFT hash", http.StatusInternalServerError)
return
}
invRes, err := neoCli.Actor.Reader().InvokeCall(nftHash, "properties", []any{tokenID}, nil)
if err != nil {
logger.Errorf("invoke call error: %v", err)
http.Error(w, "invoke call error", http.StatusInternalServerError)
return
}
// Разбираем stackitem.Map
propsMap, err := utils.DecodeStringMap(invRes.Stack[0])
if err != nil {
logger.Errorf("decode error: %v", err)
http.Error(w, "decode error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"props": propsMap,
})
}

View file

@ -0,0 +1,49 @@
package handlers
import (
"encoding/json"
"net/http"
"web3-onlyfans/services/api/internal/neo"
"github.com/nspcc-dev/neo-go/pkg/util"
"web3-onlyfans/services/api/internal/utils"
)
func TokenBalanceHandler(w http.ResponseWriter, r *http.Request, neoCli *neo.NeoClient, logger utils.Logger, cfg *utils.Config) {
accountParam := r.URL.Query().Get("account")
if accountParam == "" {
accountParam = neoCli.Actor.Sender().StringLE()
}
tokenHash, err := util.Uint160DecodeStringLE(cfg.TokenContractHash)
if err != nil {
http.Error(w, "invalid token hash", http.StatusInternalServerError)
return
}
accHash, err := util.Uint160DecodeStringLE(accountParam)
if err != nil {
http.Error(w, "invalid account param", http.StatusBadRequest)
return
}
invRes, err := neoCli.Actor.Reader().InvokeCall(tokenHash, "balanceOf", []any{accHash}, nil)
if err != nil {
logger.Errorf("balanceOf error: %v", err)
http.Error(w, "balanceOf error", http.StatusInternalServerError)
return
}
balanceI, err := utils.DecodeBigInteger(invRes.Stack[0])
if err != nil {
http.Error(w, "decode balance error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"account": accountParam,
"balance": balanceI.String(),
})
}

View file

@ -0,0 +1,6 @@
package models
type MarketItem struct {
TokenID string `json:"token_id"`
OnSale bool `json:"on_sale"`
}

View file

@ -0,0 +1,9 @@
package models
type NFTProperties struct {
Owner string `json:"owner"`
Name string `json:"name"`
BlurredRef string `json:"blurred_ref"`
FullRef string `json:"full_ref"`
Price int `json:"price"`
}

View file

@ -0,0 +1,44 @@
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
}

View file

@ -0,0 +1,36 @@
package utils
import (
"os"
"io/ioutil"
)
type Config struct {
ListenAddr string `yaml:"listenAddr"`
LogLevel string `yaml:"logLevel"`
NeoRPC string `yaml:"neoRPC"`
WalletPath string `yaml:"walletPath"`
WalletPass string `yaml:"walletPass"`
NftContractHash string `yaml:"nftContractHash"`
MarketContractHash string `yaml:"marketContractHash"`
TokenContractHash string `yaml:"tokenContractHash"`
}
func LoadConfig(path string) (*Config, error) {
content, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(content, &cfg); err != nil {
return nil, err
}
// Можно переопределить через ENV
if envListen := os.Getenv("API_LISTEN_ADDR"); envListen != "" {
cfg.ListenAddr = envListen
}
return &cfg, nil
}

View file

@ -0,0 +1,38 @@
package utils
import (
"log"
"strings"
)
type Logger interface {
Debugf(format string, v ...interface{})
Infof(format string, v ...interface{})
Errorf(format string, v ...interface{})
Fatalf(format string, v ...interface{})
}
type SimpleLogger struct {
level string
}
func NewLogger(level string) Logger {
return &SimpleLogger{level: strings.ToLower(level)}
}
func (l *SimpleLogger) Debugf(format string, v ...interface{}) {
if l.level == "debug" {
log.Printf("[DEBUG] "+format, v...)
}
}
func (l *SimpleLogger) Infof(format string, v ...interface{}) {
if l.level == "debug" || l.level == "info" {
log.Printf("[INFO] "+format, v...)
}
}
func (l *SimpleLogger) Errorf(format string, v ...interface{}) {
log.Printf("[ERROR] "+format, v...)
}
func (l *SimpleLogger) Fatalf(format string, v ...interface{}) {
log.Fatalf("[FATAL] "+format, v...)
}

View file

@ -0,0 +1,15 @@
package wallet
import (
"fmt"
"github.com/nspcc-dev/neo-go/pkg/wallet"
)
func CreateNewWallet(path, password string) error {
w, err := wallet.NewWallet(path)
if err != nil {
return fmt.Errorf("wallet new: %w", err)
}
return nil
}

View file

@ -0,0 +1,66 @@
package main
import (
"log"
"net/http"
"os"
"web3-onlyfans/services/api/internal/handlers"
"web3-onlyfans/services/api/internal/neo"
"web3-onlyfans/services/api/internal/utils"
"github.com/gorilla/mux"
)
func main() {
// Загружаем конфиг
configPath := os.Getenv("API_CONFIG_PATH")
if configPath == "" {
configPath = "./config.yaml"
}
cfg, err := utils.LoadConfig(configPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Инициализируем логгер
logger := utils.NewLogger(cfg.LogLevel)
// Инициализируем NeoClient (RPC + кошелёк)
neoCli, err := neo.NewNeoClient(cfg.NeoRPC, cfg.WalletPath, cfg.WalletPass)
if err != nil {
logger.Fatalf("failed to init neo client: %v", err)
}
// Роуты
r := mux.NewRouter()
// NFT endpoints
r.HandleFunc("/nft/mint", func(w http.ResponseWriter, r *http.Request) {
handlers.MintNFTHandler(w, r, neoCli, logger, cfg)
}).Methods("POST")
r.HandleFunc("/nft/properties", func(w http.ResponseWriter, r *http.Request) {
handlers.NFTPropertiesHandler(w, r, neoCli, logger, cfg)
}).Methods("GET")
// Market endpoints
r.HandleFunc("/market/list", func(w http.ResponseWriter, r *http.Request) {
handlers.MarketListHandler(w, r, neoCli, logger, cfg)
}).Methods("GET")
r.HandleFunc("/market/buy", func(w http.ResponseWriter, r *http.Request) {
handlers.MarketBuyHandler(w, r, neoCli, logger, cfg)
}).Methods("POST")
// Token endpoints (например, посмотреть баланс)
r.HandleFunc("/token/balance", func(w http.ResponseWriter, r *http.Request) {
handlers.TokenBalanceHandler(w, r, neoCli, logger, cfg)
}).Methods("GET")
// Запуск сервера
addr := cfg.ListenAddr
logger.Infof("API starting on %s", addr)
if err := http.ListenAndServe(addr, r); err != nil {
logger.Fatalf("server error: %v", err)
}
}

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Локальный запуск API
export API_CONFIG_PATH=./config.yaml
go run main.go

View file

@ -0,0 +1,14 @@
FROM golang:1.20 as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/uploader main.go
FROM alpine:3.17
WORKDIR /app
COPY --from=builder /app/bin/uploader /app/uploader
COPY config.yaml /app/config.yaml
EXPOSE 8081
ENTRYPOINT ["/app/uploader"]

View file

@ -0,0 +1,5 @@
listenAddr: ":8081"
logLevel: "info"
frostfsEndpoint: "grpcs://localhost:8080"
frostfsPrivKey: "0000000000000000000000000000000..."
frostfsContainer: "containerID-here"

View file

@ -0,0 +1,9 @@
module web3-onlyfans/services/frostfs-uploader
go 1.20
require (
github.com/gorilla/mux v1.8.0
github.com/nspcc-dev/neofs-sdk-go v1.2.1
# ...
)

View file

@ -0,0 +1,28 @@
package frostuploader
import (
"context"
"io"
"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"
)
func UploadFile(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
}
if _, err := writer.Write(data); err != nil {
return "", err
}
oid, err := writer.Close()
if err != nil {
return "", err
}
return oid.String(), nil
}

View file

@ -0,0 +1,65 @@
package handlers
import (
"context"
"encoding/json"
"io"
"net/http"
"github.com/nspcc-dev/neofs-sdk-go/client"
"github.com/nspcc-dev/neofs-sdk-go/container"
"web3-onlyfans/services/frostfs-uploader/internal/frostuploader"
"web3-onlyfans/services/frostfs-uploader/internal/utils"
)
func UploadHandler(w http.ResponseWriter, r *http.Request, cfg *utils.Config, logger utils.Logger) {
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file found", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read file error", http.StatusInternalServerError)
return
}
// Подключаемся к FrostFS
cli, err := client.New(client.WithDefaultPrivateKeyStr(cfg.FrostfsPrivKey))
if err != nil {
logger.Errorf("client init: %v", err)
http.Error(w, "FrostFS init error", http.StatusInternalServerError)
return
}
defer cli.Close()
if err = cli.Dial(cfg.FrostfsEndpoint); err != nil {
logger.Errorf("dial error: %v", err)
http.Error(w, "FrostFS dial error", http.StatusInternalServerError)
return
}
cntr, err := container.IDFromString(cfg.FrostfsContainer)
if err != nil {
logger.Errorf("invalid container: %v", err)
http.Error(w, "invalid container", http.StatusInternalServerError)
return
}
oid, err := frostuploader.UploadFile(context.Background(), cli, cntr, data)
if err != nil {
logger.Errorf("upload error: %v", err)
http.Error(w, "upload error", http.StatusInternalServerError)
return
}
resp := map[string]any{
"status": "ok",
"object_id": oid,
"filename": header.Filename,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}

View file

@ -0,0 +1,32 @@
package main
import (
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"web3-onlyfans/services/frostfs-uploader/internal/utils"
"web3-onlyfans/services/frostfs-uploader/internal/handlers"
)
func main() {
cfgPath := os.Getenv("FROSTFS_UPLOADER_CONFIG")
if cfgPath == "" {
cfgPath = "./config.yaml"
}
cfg, err := utils.LoadConfig(cfgPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
logger := utils.NewLogger(cfg.LogLevel)
r := mux.NewRouter()
r.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
handlers.UploadHandler(w, r, cfg, logger)
}).Methods("POST")
addr := cfg.ListenAddr
logger.Infof("frostfs-uploader on %s", addr)
log.Fatal(http.ListenAndServe(addr, r))
}

View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
export FROSTFS_UPLOADER_CONFIG=./config.yaml
go run main.go

View file

@ -0,0 +1,14 @@
FROM golang:1.20 as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/indexer main.go
FROM alpine:3.17
WORKDIR /app
COPY --from=builder /app/bin/indexer /app/indexer
COPY config.yaml /app/config.yaml
ENTRYPOINT ["/app/indexer"]

View file

@ -0,0 +1,4 @@
neoRPC: "http://localhost:20332"
logLevel: "info"
dbDsn: "postgres://indexer:password@postgres:5432/indexerdb?sslmode=disable"
pollIntervalSeconds: 3

View file

@ -0,0 +1 @@
package indexer

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS blocks (
height BIGINT PRIMARY KEY,
time TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS nft_transfers (
id SERIAL PRIMARY KEY,
block_height BIGINT NOT NULL,
tx_hash VARCHAR(66) NOT NULL,
token_id TEXT NOT NULL,
from_addr VARCHAR(42),
to_addr VARCHAR(42),
timestamp TIMESTAMP NOT NULL
);

View file

@ -0,0 +1,43 @@
package db
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
type Repository struct {
db *sql.DB
}
func NewRepository(dsn string) (*Repository, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return &Repository{db: db}, nil
}
func (r *Repository) GetLastIndexedBlock() (int64, error) {
var height int64
err := r.db.QueryRow(`SELECT height FROM blocks ORDER BY height DESC LIMIT 1`).Scan(&height)
if err == sql.ErrNoRows {
return 0, nil
}
return height, err
}
func (r *Repository) SaveBlock(height int64) error {
_, err := r.db.Exec(`INSERT INTO blocks(height,time) VALUES($1,NOW())`, height)
return err
}
func (r *Repository) SaveNFTTransfer(txHash string, blockHeight int64, tokenId, from, to string) error {
_, err := r.db.Exec(`INSERT INTO nft_transfers(block_height, tx_hash, token_id, from_addr, to_addr, timestamp)
VALUES($1, $2, $3, $4, $5, NOW())`,
blockHeight, txHash, tokenId, from, to)
return err
}

View file

@ -0,0 +1,16 @@
package models
type BlockRecord struct {
Height int64 `json:"height"`
Time string `json:"time"`
}
type NftTransferRecord struct {
ID int64 `json:"id"`
BlockHeight int64 `json:"block_height"`
TxHash string `json:"tx_hash"`
TokenId string `json:"token_id"`
FromAddr string `json:"from_addr"`
ToAddr string `json:"to_addr"`
Timestamp string `json:"timestamp"`
}

View file

@ -0,0 +1,51 @@
package subscriber
import (
"context"
"time"
"web3-onlyfans/services/indexer/internal/utils"
"github.com/nspcc-dev/neo-go/pkg/rpcclient"
)
type BlockSubscriber struct {
cfg *utils.Config
logger utils.Logger
rpc *rpcclient.Client
}
func NewBlockSubscriber(cfg *utils.Config, logger utils.Logger) (*BlockSubscriber, error) {
cli, err := rpcclient.New(context.Background(), cfg.NeoRPC, rpcclient.Options{})
if err != nil {
return nil, err
}
return &BlockSubscriber{
cfg: cfg,
logger: logger,
rpc: cli,
}, nil
}
func (s *BlockSubscriber) Start() {
pollInterval := time.Duration(s.cfg.PollIntervalSeconds) * time.Second
for {
err := s.pollOnce()
if err != nil {
s.logger.Errorf("poll error: %v", err)
}
time.Sleep(pollInterval)
}
}
func (s *BlockSubscriber) pollOnce() error {
// Здесь логика: узнаём текущий блок, сверяемся с локальным бд,
// проходимся по новым блокам, анализируем транзакции/нотификации.
height, err := s.rpc.GetBlockCount()
if err != nil {
return err
}
s.logger.Debugf("current block count: %d", height)
// ... далее - обработка новых блоков (Tx, Notifications, Transfers).
return nil
}

View file

@ -0,0 +1,36 @@
package utils
import (
"io/ioutil"
"os"
"strconv"
"gopkg.in/yaml.v2"
)
type Config struct {
NeoRPC string `yaml:"neoRPC"`
LogLevel string `yaml:"logLevel"`
DBDsn string `yaml:"dbDsn"`
PollIntervalSeconds int `yaml:"pollIntervalSeconds"`
}
func LoadConfig(path string) (*Config, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
// Переопределения через ENV
if e := os.Getenv("INDEXER_POLL_INTERVAL"); e != "" {
if val, err := strconv.Atoi(e); err == nil {
c.PollIntervalSeconds = val
}
}
return &c, nil
}

View file

@ -0,0 +1,23 @@
package utils
import (
"log"
"strings"
)
type Logger interface {
Debugf(format string, v ...interface{})
Infof(format string, v ...interface{})
Errorf(format string, v ...interface{})
Fatalf(format string, v ...interface{})
}
type SimpleLogger struct {
level string
}
func NewLogger(level string) Logger {
return &SimpleLogger{level: strings.ToLower(level)}
}
// ... тот же код, что в API

View file

@ -0,0 +1,23 @@
package main
import (
"log"
"web3-onlyfans/services/indexer/internal/subscriber"
"web3-onlyfans/services/indexer/internal/utils"
)
func main() {
cfg, err := utils.LoadConfig("./config.yaml")
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
logger := utils.NewLogger(cfg.LogLevel)
sub, err := subscriber.NewBlockSubscriber(cfg, logger)
if err != nil {
logger.Fatalf("failed to create subscriber: %v", err)
}
logger.Infof("Indexer started, polling blocks from %s", cfg.NeoRPC)
sub.Start()
}

View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
export INDEXER_CONFIG_PATH=./config.yaml
go run main.go