Files
a301_server/internal/chain/service.go
tolelom b0de89a18a
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 47s
Server CI/CD / deploy (push) Has been skipped
feat: 코드 리뷰 기반 전면 개선 — 보안, 검증, 테스트, 안정성
- 체인 nonce 경쟁 조건 수정 (operatorMu + per-user mutex)
- 등록/SSAFY 원자적 트랜잭션 (wallet+profile 롤백 보장)
- IdempotencyRequired 미들웨어 (SETNX 원자적 클레임)
- 런치 티켓 API (JWT URL 노출 방지)
- HttpOnly 쿠키 refresh token
- SSAFY OAuth state 파라미터 (CSRF 방지)
- Refresh 시 DB 조회로 최신 role 사용
- 공지사항/유저목록 페이지네이션
- BodyLimit 미들웨어 (1MB, upload 제외)
- 입력 검증 강화 (닉네임, 게임데이터, 공지 길이)
- 에러 메시지 내부 정보 노출 방지
- io.LimitReader (RPC 10MB, SSAFY 1MB)
- RequestID 비출력 문자 제거
- 단위 테스트 (auth 11, announcement 9, bossraid 16)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:25 +09:00

464 lines
12 KiB
Go

package chain
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"sync"
"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)
operatorMu sync.Mutex // serialises operator-nonce transactions
userMu sync.Map // per-user mutex (keyed by userID uint)
}
// 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")
}
uw, err := s.repo.FindByUserID(userID)
if err != nil {
return "", fmt.Errorf("wallet not found")
}
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 {
log.Printf("WARNING: wallet decryption failed for userID=%d: %v", userID, err)
return nil, "", fmt.Errorf("wallet decryption failed")
}
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)
}
// getUserMu returns a per-user mutex, creating one if it doesn't exist.
func (s *Service) getUserMu(userID uint) *sync.Mutex {
v, _ := s.userMu.LoadOrStore(userID, &sync.Mutex{})
return v.(*sync.Mutex)
}
// ---- User Transaction Methods ----
func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult, error) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
mu := s.getUserMu(userID)
mu.Lock()
defer mu.Unlock()
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) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
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) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
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) {
s.operatorMu.Lock()
defer s.operatorMu.Unlock()
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)
}