feat: 보상 재시도 + TX 확정 대기 + 에러 포맷 통일 + 품질 고도화

- 보상 지급 실패 시 즉시 재시도(3회 backoff) + DB 기록 + 백그라운드 워커 재시도
- WaitForTx 폴링으로 블록체인 TX 확정 대기, SendTxAndWait 편의 메서드
- chain 트랜잭션 코드 중복 제거 (userTx/operatorTx 헬퍼, 50% 감소)
- AppError 기반 에러 응답 포맷 통일 (8개 코드, 전 핸들러 마이그레이션)
- TX 에러 분류 + 한국어 사용자 메시지 매핑 (11가지 패턴)
- player 서비스 테스트 20개 + chain WaitForTx 테스트 10개 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:42:03 +09:00
parent 8da2bdab12
commit f4d862b47f
19 changed files with 1570 additions and 322 deletions

View File

@@ -10,6 +10,7 @@ import (
"io"
"log"
"sync"
"time"
"github.com/tolelom/tolchain/core"
tocrypto "github.com/tolelom/tolchain/crypto"
@@ -174,6 +175,17 @@ func (s *Service) getNonce(address string) (uint64, error) {
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) {
@@ -220,7 +232,9 @@ func (s *Service) getUserMu(userID uint) *sync.Mutex {
// ---- User Transaction Methods ----
func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult, error) {
// 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()
@@ -232,125 +246,53 @@ func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult
if err != nil {
return nil, err
}
tx, err := w.Transfer(s.chainID, to, amount, nonce, 0)
tx, err := buildFn(w, nonce)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
return s.submitTx(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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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)
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 ----
@@ -369,7 +311,9 @@ func (s *Service) getOperatorNonce() (uint64, error) {
return s.getNonce(s.operatorWallet.PubKey())
}
func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[string]any) (*SendTxResult, error) {
// 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 {
@@ -379,50 +323,34 @@ func (s *Service) MintAsset(templateID, ownerPubKey string, properties map[strin
if err != nil {
return nil, err
}
tx, err := s.operatorWallet.MintAsset(s.chainID, templateID, ownerPubKey, properties, nonce, 0)
tx, err := buildFn(nonce)
if err != nil {
return nil, fmt.Errorf("build tx failed: %w", err)
}
return s.client.SendTx(tx)
return s.submitTx(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) 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) 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)
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) (*SendTxResult, error) {
func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, assets []core.MintAssetPayload) (*TxStatusResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err
@@ -430,7 +358,7 @@ func (s *Service) GrantRewardByUsername(username string, tokenAmount uint64, ass
return s.GrantReward(pubKey, tokenAmount, assets)
}
func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*SendTxResult, error) {
func (s *Service) MintAssetByUsername(templateID, username string, properties map[string]any) (*TxStatusResult, error) {
pubKey, err := s.resolveUsername(username)
if err != nil {
return nil, err