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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user