CRITICAL: - graceful shutdown 레이스 수정 — Listen을 goroutine으로 이동 - Register 레이스 컨디션 — sentinel error + MySQL duplicate key 처리 HIGH: - 멱등성 키에 method+path 포함 — 엔드포인트 간 캐시 충돌 방지 - 입장 토큰 생성 실패 시 방/슬롯 롤백 추가 MEDIUM: - RequestEntry 슬롯 없음 시 503 반환 - chain ExportWallet/GetWalletInfo/GrantReward 에러 처리 개선 - resolveUsername 에러 타입 구분 (duplicate key vs 기타) - 공지사항 길이 검증 byte→rune (한국어 256자 허용) - Level 검증 범위 MaxLevel(50)로 통일 - admin 자기 강등 방지 - CORS ExposeHeaders 추가 - MySQL DSN loc=Local→loc=UTC - hashGameExeFromZip 100MB 초과 절단 감지 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
543 lines
16 KiB
Go
543 lines
16 KiB
Go
package chain
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"a301_server/pkg/apperror"
|
|
|
|
"github.com/tolelom/tolchain/core"
|
|
tocrypto "github.com/tolelom/tolchain/crypto"
|
|
"github.com/tolelom/tolchain/wallet"
|
|
"golang.org/x/crypto/hkdf"
|
|
)
|
|
|
|
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)
|
|
passwordVerifier func(userID uint, password string) 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
|
|
}
|
|
|
|
func (s *Service) SetPasswordVerifier(fn func(userID uint, password string) error) {
|
|
s.passwordVerifier = fn
|
|
}
|
|
|
|
func (s *Service) ExportPrivKey(userID uint, password string) (string, error) {
|
|
if s.passwordVerifier == nil {
|
|
return "", fmt.Errorf("password verifier not configured")
|
|
}
|
|
if err := s.passwordVerifier(userID, password); err != nil {
|
|
return "", err
|
|
}
|
|
w, _, err := s.loadUserWallet(userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return w.PrivKey().Hex(), nil
|
|
}
|
|
|
|
// resolveUsername converts a username to the user's on-chain pubKeyHex.
|
|
// If the user exists but has no wallet (e.g. legacy user or failed creation),
|
|
// a wallet is auto-created on the fly.
|
|
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 {
|
|
// 지갑이 없으면 자동 생성 시도
|
|
var createErr error
|
|
uw, createErr = s.CreateWallet(userID)
|
|
if createErr != nil {
|
|
if apperror.IsDuplicateEntry(createErr) {
|
|
// unique constraint 위반 — 다른 고루틴이 먼저 생성 완료
|
|
uw, err = s.repo.FindByUserID(userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("wallet auto-creation failed: %w", err)
|
|
}
|
|
} else {
|
|
return "", fmt.Errorf("wallet auto-creation failed: %w", createErr)
|
|
}
|
|
} else {
|
|
log.Printf("INFO: auto-created wallet for userID=%d (username=%s)", userID, 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) derivePerWalletKey(salt []byte, userID uint) ([]byte, error) {
|
|
info := []byte("wallet:" + strconv.FormatUint(uint64(userID), 10))
|
|
r := hkdf.New(sha256.New, s.encKeyBytes, salt, info)
|
|
key := make([]byte, 32)
|
|
if _, err := io.ReadFull(r, key); err != nil {
|
|
return nil, fmt.Errorf("HKDF key derivation failed: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *Service) encryptPrivKeyV2(privKey tocrypto.PrivateKey, userID uint) (cipherHex, nonceHex, saltHex string, err error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
key, err := s.derivePerWalletKey(salt, userID)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
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), hex.EncodeToString(salt), nil
|
|
}
|
|
|
|
func (s *Service) decryptPrivKeyV2(cipherHex, nonceHex, saltHex string, userID uint) (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
|
|
}
|
|
salt, err := hex.DecodeString(saltHex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := s.derivePerWalletKey(salt, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
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 Migration ----
|
|
|
|
// MigrateWalletKeys re-encrypts all v1 wallets using HKDF per-wallet keys.
|
|
// Each wallet is migrated individually; failures are logged and skipped.
|
|
func (s *Service) MigrateWalletKeys() error {
|
|
wallets, err := s.repo.FindAllByKeyVersion(1)
|
|
if err != nil {
|
|
return fmt.Errorf("query v1 wallets: %w", err)
|
|
}
|
|
if len(wallets) == 0 {
|
|
return nil
|
|
}
|
|
log.Printf("INFO: migrating %d v1 wallets to v2 (HKDF)", len(wallets))
|
|
var migrated, failed int
|
|
for _, uw := range wallets {
|
|
privKey, err := s.decryptPrivKey(uw.EncryptedPrivKey, uw.EncNonce)
|
|
if err != nil {
|
|
log.Printf("ERROR: v1 decrypt failed for walletID=%d userID=%d: %v", uw.ID, uw.UserID, err)
|
|
failed++
|
|
continue
|
|
}
|
|
cipherHex, nonceHex, saltHex, err := s.encryptPrivKeyV2(privKey, uw.UserID)
|
|
if err != nil {
|
|
log.Printf("ERROR: v2 encrypt failed for walletID=%d userID=%d: %v", uw.ID, uw.UserID, err)
|
|
failed++
|
|
continue
|
|
}
|
|
if err := s.repo.UpdateEncryption(uw.ID, cipherHex, nonceHex, saltHex, 2); err != nil {
|
|
log.Printf("ERROR: DB update failed for walletID=%d userID=%d: %v", uw.ID, uw.UserID, err)
|
|
failed++
|
|
continue
|
|
}
|
|
migrated++
|
|
}
|
|
log.Printf("INFO: wallet migration complete: %d migrated, %d failed", migrated, failed)
|
|
return 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, saltHex, err := s.encryptPrivKeyV2(w.PrivKey(), userID)
|
|
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,
|
|
KeyVersion: 2,
|
|
HKDFSalt: saltHex,
|
|
}
|
|
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)
|
|
}
|
|
var privKey tocrypto.PrivateKey
|
|
if uw.KeyVersion >= 2 {
|
|
privKey, err = s.decryptPrivKeyV2(uw.EncryptedPrivKey, uw.EncNonce, uw.HKDFSalt, uw.UserID)
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// txConfirmTimeout is the maximum time to wait for a transaction to be
|
|
// included in a block. PoA block intervals are typically a few seconds,
|
|
// so 15s provides ample margin.
|
|
const txConfirmTimeout = 15 * time.Second
|
|
|
|
// submitTx sends a signed transaction and waits for block confirmation.
|
|
// Returns the confirmed status or an error (including TxError for on-chain failures).
|
|
func (s *Service) submitTx(tx any) (*TxStatusResult, error) {
|
|
return s.client.SendTxAndWait(tx, txConfirmTimeout)
|
|
}
|
|
|
|
// ---- 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 ----
|
|
|
|
// userTx handles the common boilerplate for user transactions:
|
|
// acquire per-user mutex → load wallet → get nonce → build tx → submit.
|
|
func (s *Service) userTx(userID uint, buildFn func(w *wallet.Wallet, nonce uint64) (any, error)) (*TxStatusResult, 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 := buildFn(w, nonce)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build tx failed: %w", err)
|
|
}
|
|
return s.submitTx(tx)
|
|
}
|
|
|
|
func (s *Service) Transfer(userID uint, to string, amount uint64) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.Transfer(s.chainID, to, amount, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) TransferAsset(userID uint, assetID, to string) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.TransferAsset(s.chainID, assetID, to, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) ListOnMarket(userID uint, assetID string, price uint64) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.ListMarket(s.chainID, assetID, price, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) BuyFromMarket(userID uint, listingID string) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.BuyMarket(s.chainID, listingID, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) CancelListing(userID uint, listingID string) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.CancelListing(s.chainID, listingID, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) EquipItem(userID uint, assetID, slot string) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.EquipItem(s.chainID, assetID, slot, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) UnequipItem(userID uint, assetID string) (*TxStatusResult, error) {
|
|
return s.userTx(userID, func(w *wallet.Wallet, nonce uint64) (any, error) {
|
|
return w.UnequipItem(s.chainID, assetID, nonce, 0)
|
|
})
|
|
}
|
|
|
|
// ---- 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())
|
|
}
|
|
|
|
// operatorTx handles the common boilerplate for operator transactions:
|
|
// acquire operator mutex → ensure operator → get nonce → build tx → submit.
|
|
func (s *Service) operatorTx(buildFn func(nonce uint64) (any, error)) (*TxStatusResult, 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 := buildFn(nonce)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build tx failed: %w", err)
|
|
}
|
|
return s.submitTx(tx)
|
|
}
|
|
|
|
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*TxStatusResult, error) {
|
|
return s.operatorTx(func(nonce uint64) (any, error) {
|
|
return s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) GrantReward(recipientPubKey string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
|
|
return s.operatorTx(func(nonce uint64) (any, error) {
|
|
return s.operatorWallet.GrantReward(s.chainID, recipientPubKey, tokenAmount, assets, nonce, 0)
|
|
})
|
|
}
|
|
|
|
func (s *Service) RegisterTemplate(id, name string, schema map[string]any, tradeable bool) (*TxStatusResult, error) {
|
|
return s.operatorTx(func(nonce uint64) (any, error) {
|
|
return s.operatorWallet.RegisterTemplate(s.chainID, id, name, schema, tradeable, nonce, 0)
|
|
})
|
|
}
|
|
|
|
// ---- Username-based Methods (for game server) ----
|
|
|
|
func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, 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) (*TxStatusResult, 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)
|
|
}
|