feat: 블록체인(chain) 통합 및 내부 API 추가
All checks were successful
Server CI/CD / deploy (push) Successful in 7s

- internal/chain 패키지 추가 (client, handler, service, repository, model)
- 체인 연동 엔드포인트: 지갑 조회, 잔액, 자산, 인벤토리, 마켓 등
- 관리자 전용 체인 엔드포인트: 민팅, 보상, 템플릿 등록
- 게임 서버용 내부 API (/api/internal/chain/*) + ServerAuth 미들웨어
- 회원가입 시 블록체인 월렛 자동 생성
- 체인 관련 환경변수 및 InternalAPIKey 설정 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:18:15 +09:00
parent 1b6260ee4e
commit f8b23e93bf
12 changed files with 1173 additions and 16 deletions

View File

@@ -3,6 +3,7 @@ package auth
import (
"context"
"fmt"
"log"
"time"
"a301_server/pkg/config"
@@ -19,14 +20,21 @@ type Claims struct {
}
type Service struct {
repo *Repository
rdb *redis.Client
repo *Repository
rdb *redis.Client
walletCreator func(userID uint) error
}
func NewService(repo *Repository, rdb *redis.Client) *Service {
return &Service{repo: repo, rdb: rdb}
}
// SetWalletCreator sets the callback invoked after user registration
// to create a blockchain wallet.
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
s.walletCreator = fn
}
func (s *Service) Login(username, password string) (string, *User, error) {
user, err := s.repo.FindByUsername(username)
if err != nil {
@@ -85,11 +93,20 @@ func (s *Service) Register(username, password string) error {
if err != nil {
return fmt.Errorf("비밀번호 처리에 실패했습니다")
}
return s.repo.Create(&User{
user := &User{
Username: username,
PasswordHash: string(hash),
Role: RoleUser,
})
}
if err := s.repo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
log.Printf("WARNING: wallet creation failed for user %d: %v", user.ID, err)
}
}
return nil
}
// VerifyToken validates a JWT and its Redis session, returning (username, error).

145
internal/chain/client.go Normal file
View File

@@ -0,0 +1,145 @@
package chain
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync/atomic"
"time"
)
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params any `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string {
return fmt.Sprintf("RPC error %d: %s", e.Code, e.Message)
}
// Client is a JSON-RPC 2.0 client for the TOL Chain node.
type Client struct {
nodeURL string
http *http.Client
idSeq atomic.Int64
}
func NewClient(nodeURL string) *Client {
return &Client{
nodeURL: nodeURL,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// Call invokes a JSON-RPC method and unmarshals the result into out.
func (c *Client) Call(method string, params any, out any) error {
reqBody := rpcRequest{
JSONRPC: "2.0",
ID: c.idSeq.Add(1),
Method: method,
Params: params,
}
data, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal RPC request: %w", err)
}
resp, err := c.http.Post(c.nodeURL, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("RPC network error: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read RPC response: %w", err)
}
var rpcResp rpcResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return fmt.Errorf("unmarshal RPC response: %w", err)
}
if rpcResp.Error != nil {
return rpcResp.Error
}
if out != nil {
if err := json.Unmarshal(rpcResp.Result, out); err != nil {
return fmt.Errorf("unmarshal RPC result: %w", err)
}
}
return nil
}
// --- Typed convenience methods ---
type BalanceResult struct {
Address string `json:"address"`
Balance uint64 `json:"balance"`
Nonce uint64 `json:"nonce"`
}
func (c *Client) GetBalance(address string) (*BalanceResult, error) {
var result BalanceResult
err := c.Call("getBalance", map[string]string{"address": address}, &result)
return &result, err
}
func (c *Client) GetAsset(id string) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getAsset", map[string]string{"id": id}, &result)
return result, err
}
func (c *Client) GetAssetsByOwner(owner string, offset, limit int) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getAssetsByOwner", map[string]any{
"owner": owner, "offset": offset, "limit": limit,
}, &result)
return result, err
}
func (c *Client) GetInventory(owner string) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getInventory", map[string]string{"owner": owner}, &result)
return result, err
}
func (c *Client) GetActiveListings(offset, limit int) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getActiveListings", map[string]any{
"offset": offset, "limit": limit,
}, &result)
return result, err
}
func (c *Client) GetListing(id string) (json.RawMessage, error) {
var result json.RawMessage
err := c.Call("getListing", map[string]string{"id": id}, &result)
return result, err
}
type SendTxResult struct {
TxID string `json:"tx_id"`
}
func (c *Client) SendTx(tx any) (*SendTxResult, error) {
var result SendTxResult
err := c.Call("sendTx", tx, &result)
return &result, err
}

375
internal/chain/handler.go Normal file
View File

@@ -0,0 +1,375 @@
package chain
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/tolelom/tolchain/core"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// ---- Query Handlers ----
func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
w, err := h.svc.GetWallet(userID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
}
return c.JSON(fiber.Map{
"address": w.Address,
"pubKeyHex": w.PubKeyHex,
})
}
func (h *Handler) GetBalance(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
result, err := h.svc.GetBalance(userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) GetAssets(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
result, err := h.svc.GetAssets(userID, offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
func (h *Handler) GetAsset(c *fiber.Ctx) error {
assetID := c.Params("id")
if assetID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "asset id is required"})
}
result, err := h.svc.GetAsset(assetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
func (h *Handler) GetInventory(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
result, err := h.svc.GetInventory(userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
result, err := h.svc.GetMarketListings(offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
listingID := c.Params("id")
if listingID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listing id is required"})
}
result, err := h.svc.GetListing(listingID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// ---- User Transaction Handlers ----
func (h *Handler) Transfer(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
To string `json:"to"`
Amount uint64 `json:"amount"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.To == "" || req.Amount == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"})
}
result, err := h.svc.Transfer(userID, req.To, req.Amount)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) TransferAsset(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
AssetID string `json:"assetId"`
To string `json:"to"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" || req.To == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"})
}
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
AssetID string `json:"assetId"`
Price uint64 `json:"price"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" || req.Price == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"})
}
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ListingID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
}
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) CancelListing(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
ListingID string `json:"listingId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ListingID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
}
result, err := h.svc.CancelListing(userID, req.ListingID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) EquipItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
AssetID string `json:"assetId"`
Slot string `json:"slot"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" || req.Slot == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"})
}
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
func (h *Handler) UnequipItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
var req struct {
AssetID string `json:"assetId"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.AssetID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"})
}
result, err := h.svc.UnequipItem(userID, req.AssetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
// ---- Operator (Admin) Transaction Handlers ----
func (h *Handler) MintAsset(c *fiber.Ctx) error {
var req struct {
TemplateID string `json:"templateId"`
OwnerPubKey string `json:"ownerPubKey"`
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.TemplateID == "" || req.OwnerPubKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"})
}
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(result)
}
func (h *Handler) GrantReward(c *fiber.Ctx) error {
var req struct {
RecipientPubKey string `json:"recipientPubKey"`
TokenAmount uint64 `json:"tokenAmount"`
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.RecipientPubKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"})
}
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(result)
}
func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Schema map[string]any `json:"schema"`
Tradeable bool `json:"tradeable"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.ID == "" || req.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"})
}
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// ---- Internal Handlers (game server, username-based) ----
// InternalGrantReward grants reward by username. For game server use.
func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
var req struct {
Username string `json:"username"`
TokenAmount uint64 `json:"tokenAmount"`
Assets []core.MintAssetPayload `json:"assets"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.Username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// InternalMintAsset mints an asset by username. For game server use.
func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
var req struct {
TemplateID string `json:"templateId"`
Username string `json:"username"`
Properties map[string]any `json:"properties"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
}
if req.TemplateID == "" || req.Username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"})
}
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(result)
}
// InternalGetBalance returns balance by username. For game server use.
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
result, err := h.svc.GetBalanceByUsername(username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(result)
}
// InternalGetAssets returns assets by username. For game server use.
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
// InternalGetInventory returns inventory by username. For game server use.
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
username := c.Query("username")
if username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
result, err := h.svc.GetInventoryByUsername(username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}

20
internal/chain/model.go Normal file
View File

@@ -0,0 +1,20 @@
package chain
import (
"time"
"gorm.io/gorm"
)
// UserWallet stores an encrypted ed25519 keypair linked to a user.
type UserWallet struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
UserID uint `json:"userId" gorm:"uniqueIndex;not null"`
PubKeyHex string `json:"pubKeyHex" gorm:"type:varchar(64);uniqueIndex;not null"`
Address string `json:"address" gorm:"type:varchar(40);uniqueIndex;not null"`
EncryptedPrivKey string `json:"-" gorm:"type:varchar(512);not null"`
EncNonce string `json:"-" gorm:"type:varchar(48);not null"`
}

View File

@@ -0,0 +1,27 @@
package chain
import "gorm.io/gorm"
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(w *UserWallet) error {
return r.db.Create(w).Error
}
func (r *Repository) FindByUserID(userID uint) (*UserWallet, error) {
var w UserWallet
err := r.db.Where("user_id = ?", userID).First(&w).Error
return &w, err
}
func (r *Repository) FindByPubKeyHex(pubKeyHex string) (*UserWallet, error) {
var w UserWallet
err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error
return &w, err
}

425
internal/chain/service.go Normal file
View File

@@ -0,0 +1,425 @@
package chain
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"github.com/tolelom/tolchain/core"
tocrypto "github.com/tolelom/tolchain/crypto"
"github.com/tolelom/tolchain/wallet"
)
type Service struct {
repo *Repository
client *Client
chainID string
operatorWallet *wallet.Wallet
encKeyBytes []byte // 32-byte AES-256 key
userResolver func(username string) (uint, error)
}
// SetUserResolver sets the callback that resolves username → userID.
func (s *Service) SetUserResolver(fn func(username string) (uint, error)) {
s.userResolver = fn
}
// resolveUsername converts a username to the user's on-chain pubKeyHex.
func (s *Service) resolveUsername(username string) (string, error) {
if s.userResolver == nil {
return "", fmt.Errorf("user resolver not configured")
}
userID, err := s.userResolver(username)
if err != nil {
return "", fmt.Errorf("user not found: %s", username)
}
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return "", fmt.Errorf("wallet not found for user: %s", username)
}
return uw.PubKeyHex, nil
}
func NewService(
repo *Repository,
client *Client,
chainID string,
operatorKeyHex string,
walletEncKeyHex string,
) (*Service, error) {
encKey, err := hex.DecodeString(walletEncKeyHex)
if err != nil || len(encKey) != 32 {
return nil, fmt.Errorf("WALLET_ENCRYPTION_KEY must be 64 hex chars (32 bytes)")
}
var opWallet *wallet.Wallet
if operatorKeyHex != "" {
privKey, err := tocrypto.PrivKeyFromHex(operatorKeyHex)
if err != nil {
return nil, fmt.Errorf("invalid OPERATOR_KEY_HEX: %w", err)
}
opWallet = wallet.New(privKey)
}
return &Service{
repo: repo,
client: client,
chainID: chainID,
operatorWallet: opWallet,
encKeyBytes: encKey,
}, nil
}
// ---- Wallet Encryption (AES-256-GCM) ----
func (s *Service) encryptPrivKey(privKey tocrypto.PrivateKey) (cipherHex, nonceHex string, err error) {
block, err := aes.NewCipher(s.encKeyBytes)
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", "", err
}
cipherText := gcm.Seal(nil, nonce, []byte(privKey), nil)
return hex.EncodeToString(cipherText), hex.EncodeToString(nonce), nil
}
func (s *Service) decryptPrivKey(cipherHex, nonceHex string) (tocrypto.PrivateKey, error) {
cipherText, err := hex.DecodeString(cipherHex)
if err != nil {
return nil, err
}
nonce, err := hex.DecodeString(nonceHex)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(s.encKeyBytes)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
plaintext, err := gcm.Open(nil, nonce, cipherText, nil)
if err != nil {
return nil, fmt.Errorf("wallet decryption failed: %w", err)
}
return tocrypto.PrivateKey(plaintext), nil
}
// ---- Wallet Management ----
// CreateWallet generates a new keypair, encrypts it, and stores in DB.
func (s *Service) CreateWallet(userID uint) (*UserWallet, error) {
w, err := wallet.Generate()
if err != nil {
return nil, fmt.Errorf("key generation failed: %w", err)
}
cipherHex, nonceHex, err := s.encryptPrivKey(w.PrivKey())
if err != nil {
return nil, fmt.Errorf("key encryption failed: %w", err)
}
uw := &UserWallet{
UserID: userID,
PubKeyHex: w.PubKey(),
Address: w.Address(),
EncryptedPrivKey: cipherHex,
EncNonce: nonceHex,
}
if err := s.repo.Create(uw); err != nil {
return nil, fmt.Errorf("wallet save failed: %w", err)
}
return uw, nil
}
func (s *Service) GetWallet(userID uint) (*UserWallet, error) {
return s.repo.FindByUserID(userID)
}
// loadUserWallet decrypts a user's private key and returns a wallet.Wallet.
func (s *Service) loadUserWallet(userID uint) (*wallet.Wallet, string, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, "", fmt.Errorf("wallet not found: %w", err)
}
privKey, err := s.decryptPrivKey(uw.EncryptedPrivKey, uw.EncNonce)
if err != nil {
return nil, "", err
}
return wallet.New(privKey), uw.PubKeyHex, nil
}
func (s *Service) getNonce(address string) (uint64, error) {
bal, err := s.client.GetBalance(address)
if err != nil {
return 0, fmt.Errorf("get nonce failed: %w", err)
}
return bal.Nonce, nil
}
// ---- Query Methods ----
func (s *Service) GetBalance(userID uint) (*BalanceResult, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("wallet not found: %w", err)
}
return s.client.GetBalance(uw.PubKeyHex)
}
func (s *Service) GetAssets(userID uint, offset, limit int) (json.RawMessage, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("wallet not found: %w", err)
}
return s.client.GetAssetsByOwner(uw.PubKeyHex, offset, limit)
}
func (s *Service) GetAsset(assetID string) (json.RawMessage, error) {
return s.client.GetAsset(assetID)
}
func (s *Service) GetInventory(userID uint) (json.RawMessage, error) {
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return nil, fmt.Errorf("wallet not found: %w", err)
}
return s.client.GetInventory(uw.PubKeyHex)
}
func (s *Service) GetMarketListings(offset, limit int) (json.RawMessage, error) {
return s.client.GetActiveListings(offset, limit)
}
func (s *Service) GetListing(listingID string) (json.RawMessage, error) {
return s.client.GetListing(listingID)
}
// ---- User Transaction Methods ----
func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.Transfer(s.chainID, to, amount, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) TransferAsset(userID uint, assetID, to string) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.TransferAsset(s.chainID, assetID, to, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.ListMarket(s.chainID, assetID, price, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) BuyFromMarket(userID uint, listingID string) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.BuyMarket(s.chainID, listingID, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) CancelListing(userID uint, listingID string) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.CancelListing(s.chainID, listingID, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) EquipItem(userID uint, assetID, slot string) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.EquipItem(s.chainID, assetID, slot, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) UnequipItem(userID uint, assetID string) (*SendTxResult, error) {
w, pubKey, err := s.loadUserWallet(userID)
if err != nil {
return nil, err
}
nonce, err := s.getNonce(pubKey)
if err != nil {
return nil, err
}
tx, err := w.UnequipItem(s.chainID, assetID, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
// ---- Operator Transaction Methods ----
func (s *Service) ensureOperator() error {
if s.operatorWallet == nil {
return fmt.Errorf("operator wallet not configured")
}
return nil
}
func (s *Service) getOperatorNonce() (uint64, error) {
if err := s.ensureOperator(); err != nil {
return 0, err
}
return s.getNonce(s.operatorWallet.PubKey())
}
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*SendTxResult, error) {
if err := s.ensureOperator(); err != nil {
return nil, err
}
nonce, err := s.getOperatorNonce()
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*SendTxResult, error) {
if err := s.ensureOperator(); err != nil {
return nil, err
}
nonce, err := s.getOperatorNonce()
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*SendTxResult, error) {
if err := s.ensureOperator(); err != nil {
return nil, err
}
nonce, err := s.getOperatorNonce()
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
}
// ---- Username-based Methods (for game server) ----
func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*SendTxResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.GrantReward(pubKey, tokenAmount, assets)
}
func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*SendTxResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.MintAsset(templateID, pubKey, properties)
}
func (s *Service) GetBalanceByUsername(username string) (*BalanceResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.client.GetBalance(pubKey)
}
func (s *Service) GetAssetsByUsername(username string, offset, limit int) (json.RawMessage, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.client.GetAssetsByOwner(pubKey, offset, limit)
}
func (s *Service) GetInventoryByUsername(username string) (json.RawMessage, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
}
return s.client.GetInventory(pubKey)
}