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:
@@ -147,3 +147,80 @@ func (c *Client) SendTx(tx any) (*SendTxResult, error) {
|
||||
err := c.Call("sendTx", tx, &result)
|
||||
return &result, err
|
||||
}
|
||||
|
||||
// TxStatusResult mirrors the indexer.TxResult from the TOL Chain node.
|
||||
type TxStatusResult struct {
|
||||
TxID string `json:"tx_id"`
|
||||
BlockHeight int64 `json:"block_height"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// GetTxStatus queries the execution result of a transaction.
|
||||
// Returns nil result (no error) if the transaction has not been included in a block yet.
|
||||
func (c *Client) GetTxStatus(txID string) (*TxStatusResult, error) {
|
||||
var result *TxStatusResult
|
||||
err := c.Call("getTxStatus", map[string]string{"tx_id": txID}, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TxError is returned when a transaction was included in a block but execution failed.
|
||||
type TxError struct {
|
||||
TxID string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *TxError) Error() string {
|
||||
return fmt.Sprintf("transaction %s failed: %s", e.TxID, e.Message)
|
||||
}
|
||||
|
||||
// DefaultTxTimeout is the default timeout for WaitForTx. PoA block intervals
|
||||
// are typically a few seconds, so 15s provides ample margin.
|
||||
const DefaultTxTimeout = 15 * time.Second
|
||||
|
||||
// SendTxAndWait sends a transaction and waits for block confirmation.
|
||||
// It combines SendTx + WaitForTx for the common fire-and-confirm pattern.
|
||||
func (c *Client) SendTxAndWait(tx any, timeout time.Duration) (*TxStatusResult, error) {
|
||||
sendResult, err := c.SendTx(tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send tx: %w", err)
|
||||
}
|
||||
return c.WaitForTx(sendResult.TxID, timeout)
|
||||
}
|
||||
|
||||
// WaitForTx polls getTxStatus until the transaction is included in a block or
|
||||
// the timeout is reached. It returns the confirmed TxStatusResult on success,
|
||||
// a TxError if the transaction executed but failed, or a timeout error.
|
||||
func (c *Client) WaitForTx(txID string, timeout time.Duration) (*TxStatusResult, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
interval := 200 * time.Millisecond
|
||||
|
||||
for {
|
||||
result, err := c.GetTxStatus(txID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getTxStatus: %w", err)
|
||||
}
|
||||
if result != nil {
|
||||
if !result.Success {
|
||||
return result, &TxError{TxID: txID, Message: result.Error}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("transaction %s not confirmed within %s", txID, timeout)
|
||||
}
|
||||
|
||||
time.Sleep(interval)
|
||||
// Increase interval up to 1s to reduce polling pressure.
|
||||
if interval < time.Second {
|
||||
interval = interval * 3 / 2
|
||||
if interval > time.Second {
|
||||
interval = time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
333
internal/chain/client_test.go
Normal file
333
internal/chain/client_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rpcHandler returns an http.HandlerFunc that responds with JSON-RPC results.
|
||||
// The handleFn receives the method and params, and returns the result or an error string.
|
||||
func rpcHandler(handleFn func(method string, params json.RawMessage) (any, string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID any `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
result, errMsg := handleFn(req.Method, req.Params)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if errMsg != "" {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"error": map[string]any{"code": -32000, "message": errMsg},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": json.RawMessage(resultJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForTx_Success(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
if method != "getTxStatus" {
|
||||
return nil, "unexpected method"
|
||||
}
|
||||
// First call returns null (not yet confirmed), second returns success
|
||||
if calls.Add(1) == 1 {
|
||||
return nil, ""
|
||||
}
|
||||
return &TxStatusResult{
|
||||
TxID: "tx-123",
|
||||
BlockHeight: 42,
|
||||
Success: true,
|
||||
}, ""
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.WaitForTx("tx-123", 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForTx should succeed: %v", err)
|
||||
}
|
||||
if result.TxID != "tx-123" {
|
||||
t.Errorf("TxID = %q, want %q", result.TxID, "tx-123")
|
||||
}
|
||||
if result.BlockHeight != 42 {
|
||||
t.Errorf("BlockHeight = %d, want 42", result.BlockHeight)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("Success should be true")
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("expected 2 RPC calls, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForTx_Timeout(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
// Always return null — transaction never confirms
|
||||
return nil, ""
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
timeout := 500 * time.Millisecond
|
||||
start := time.Now()
|
||||
result, err := client.WaitForTx("tx-never", timeout)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("WaitForTx should return timeout error")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("result should be nil on timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not confirmed within") {
|
||||
t.Errorf("error should mention timeout, got: %v", err)
|
||||
}
|
||||
// Should have waited at least the timeout duration
|
||||
if elapsed < timeout {
|
||||
t.Errorf("elapsed %v is less than timeout %v", elapsed, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForTx_TxFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
return &TxStatusResult{
|
||||
TxID: "tx-fail",
|
||||
BlockHeight: 10,
|
||||
Success: false,
|
||||
Error: "insufficient balance: have 0 need 100",
|
||||
}, ""
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.WaitForTx("tx-fail", 5*time.Second)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("WaitForTx should return TxError for failed transaction")
|
||||
}
|
||||
|
||||
// Should return a TxError
|
||||
var txErr *TxError
|
||||
if !errors.As(err, &txErr) {
|
||||
t.Fatalf("error should be *TxError, got %T: %v", err, err)
|
||||
}
|
||||
if txErr.TxID != "tx-fail" {
|
||||
t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-fail")
|
||||
}
|
||||
if !strings.Contains(txErr.Message, "insufficient balance") {
|
||||
t.Errorf("TxError.Message should contain 'insufficient balance', got %q", txErr.Message)
|
||||
}
|
||||
|
||||
// Result should still be returned even on TxError
|
||||
if result == nil {
|
||||
t.Fatal("result should be non-nil even on TxError")
|
||||
}
|
||||
if result.Success {
|
||||
t.Error("result.Success should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForTx_RPCError(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
return nil, "internal server error"
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.WaitForTx("tx-rpc-err", 2*time.Second)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("WaitForTx should return error on RPC failure")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("result should be nil on RPC error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "getTxStatus") {
|
||||
t.Errorf("error should wrap getTxStatus context, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTxAndWait_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
switch method {
|
||||
case "sendTx":
|
||||
return &SendTxResult{TxID: "tx-abc"}, ""
|
||||
case "getTxStatus":
|
||||
return &TxStatusResult{
|
||||
TxID: "tx-abc",
|
||||
BlockHeight: 5,
|
||||
Success: true,
|
||||
}, ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unexpected method: %s", method)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("SendTxAndWait should succeed: %v", err)
|
||||
}
|
||||
if result.TxID != "tx-abc" {
|
||||
t.Errorf("TxID = %q, want %q", result.TxID, "tx-abc")
|
||||
}
|
||||
if result.BlockHeight != 5 {
|
||||
t.Errorf("BlockHeight = %d, want 5", result.BlockHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTxAndWait_SendError(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
return nil, "mempool full"
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("SendTxAndWait should fail when sendTx fails")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("result should be nil on send error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "send tx") {
|
||||
t.Errorf("error should wrap send tx context, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTxAndWait_TxFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
switch method {
|
||||
case "sendTx":
|
||||
return &SendTxResult{TxID: "tx-will-fail"}, ""
|
||||
case "getTxStatus":
|
||||
return &TxStatusResult{
|
||||
TxID: "tx-will-fail",
|
||||
BlockHeight: 7,
|
||||
Success: false,
|
||||
Error: "asset is not tradeable",
|
||||
}, ""
|
||||
default:
|
||||
return nil, "unexpected"
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("SendTxAndWait should return error for failed tx")
|
||||
}
|
||||
|
||||
var txErr *TxError
|
||||
if !errors.As(err, &txErr) {
|
||||
t.Fatalf("error should be *TxError, got %T: %v", err, err)
|
||||
}
|
||||
if txErr.TxID != "tx-will-fail" {
|
||||
t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-will-fail")
|
||||
}
|
||||
|
||||
// Result still returned with failure details
|
||||
if result == nil {
|
||||
t.Fatal("result should be non-nil even on TxError")
|
||||
}
|
||||
if result.Success {
|
||||
t.Error("result.Success should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForTx_PollingBackoff(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
confirmAfter := int32(5) // confirm on the 5th call
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
n := calls.Add(1)
|
||||
if n < confirmAfter {
|
||||
return nil, ""
|
||||
}
|
||||
return &TxStatusResult{
|
||||
TxID: "tx-backoff",
|
||||
BlockHeight: 99,
|
||||
Success: true,
|
||||
}, ""
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.WaitForTx("tx-backoff", 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForTx should succeed: %v", err)
|
||||
}
|
||||
if result.TxID != "tx-backoff" {
|
||||
t.Errorf("TxID = %q, want %q", result.TxID, "tx-backoff")
|
||||
}
|
||||
if calls.Load() != confirmAfter {
|
||||
t.Errorf("expected %d RPC calls, got %d", confirmAfter, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTxStatus_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
// Return null result — tx not yet in a block
|
||||
return nil, ""
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.GetTxStatus("tx-pending")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTxStatus should not error for pending tx: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("result should be nil for pending tx, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTxStatus_Found(t *testing.T) {
|
||||
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
||||
return &TxStatusResult{
|
||||
TxID: "tx-done",
|
||||
BlockHeight: 100,
|
||||
Success: true,
|
||||
}, ""
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL)
|
||||
result, err := client.GetTxStatus("tx-done")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTxStatus should succeed: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("result should not be nil for confirmed tx")
|
||||
}
|
||||
if result.TxID != "tx-done" || result.BlockHeight != 100 || !result.Success {
|
||||
t.Errorf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"a301_server/pkg/apperror"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/tolelom/tolchain/core"
|
||||
@@ -22,7 +26,7 @@ func NewHandler(svc *Service) *Handler {
|
||||
func getUserID(c *fiber.Ctx) (uint, error) {
|
||||
uid, ok := c.Locals("userID").(uint)
|
||||
if !ok {
|
||||
return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다")
|
||||
return 0, apperror.ErrUnauthorized
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
@@ -45,9 +49,47 @@ func validID(s string) bool {
|
||||
return s != "" && len(s) <= maxIDLength
|
||||
}
|
||||
|
||||
func chainError(c *fiber.Ctx, status int, userMsg string, err error) error {
|
||||
// chainError classifies chain errors into appropriate HTTP responses.
|
||||
// TxError (on-chain execution failure) maps to 422 with the chain's error detail.
|
||||
// Other errors (network, timeout, build failures) remain 500.
|
||||
func chainError(userMsg string, err error) *apperror.AppError {
|
||||
log.Printf("chain error: %s: %v", userMsg, err)
|
||||
return c.Status(status).JSON(fiber.Map{"error": userMsg})
|
||||
|
||||
var txErr *TxError
|
||||
if errors.As(err, &txErr) {
|
||||
msg := classifyTxError(txErr.Message)
|
||||
return apperror.New("tx_failed", msg, 422)
|
||||
}
|
||||
return apperror.Internal(userMsg)
|
||||
}
|
||||
|
||||
// classifyTxError translates raw chain error messages into user-friendly Korean messages.
|
||||
func classifyTxError(chainMsg string) string {
|
||||
lower := strings.ToLower(chainMsg)
|
||||
switch {
|
||||
case strings.Contains(lower, "insufficient balance"):
|
||||
return "잔액이 부족합니다"
|
||||
case strings.Contains(lower, "unauthorized"):
|
||||
return "권한이 없습니다"
|
||||
case strings.Contains(lower, "already listed"):
|
||||
return "이미 마켓에 등록된 아이템입니다"
|
||||
case strings.Contains(lower, "already exists"):
|
||||
return "이미 존재합니다"
|
||||
case strings.Contains(lower, "not found"):
|
||||
return "리소스를 찾을 수 없습니다"
|
||||
case strings.Contains(lower, "not tradeable"):
|
||||
return "거래할 수 없는 아이템입니다"
|
||||
case strings.Contains(lower, "equipped"):
|
||||
return "장착 중인 아이템입니다"
|
||||
case strings.Contains(lower, "not active"):
|
||||
return "활성 상태가 아닌 매물입니다"
|
||||
case strings.Contains(lower, "not open"):
|
||||
return "진행 중이 아닌 세션입니다"
|
||||
case strings.Contains(lower, "invalid nonce"):
|
||||
return "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요"
|
||||
default:
|
||||
return "블록체인 트랜잭션이 실패했습니다"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Query Handlers ----
|
||||
@@ -69,7 +111,7 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
w, err := h.svc.GetWallet(userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
|
||||
return apperror.NotFound("지갑을 찾을 수 없습니다")
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"address": w.Address,
|
||||
@@ -94,7 +136,7 @@ func (h *Handler) GetBalance(c *fiber.Ctx) error {
|
||||
}
|
||||
result, err := h.svc.GetBalance(userID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
|
||||
return chainError("잔액 조회에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -119,7 +161,7 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
|
||||
offset, limit := parsePagination(c)
|
||||
result, err := h.svc.GetAssets(userID, offset, limit)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
||||
return chainError("에셋 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
@@ -140,11 +182,11 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error {
|
||||
func (h *Handler) GetAsset(c *fiber.Ctx) error {
|
||||
assetID := c.Params("id")
|
||||
if !validID(assetID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 asset id가 필요합니다"})
|
||||
return apperror.BadRequest("유효한 asset id가 필요합니다")
|
||||
}
|
||||
result, err := h.svc.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
||||
return chainError("에셋 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
@@ -167,7 +209,7 @@ func (h *Handler) GetInventory(c *fiber.Ctx) error {
|
||||
}
|
||||
result, err := h.svc.GetInventory(userID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
|
||||
return chainError("인벤토리 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
@@ -188,7 +230,7 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
|
||||
offset, limit := parsePagination(c)
|
||||
result, err := h.svc.GetMarketListings(offset, limit)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
|
||||
return chainError("마켓 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
@@ -208,11 +250,11 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
|
||||
func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
|
||||
listingID := c.Params("id")
|
||||
if !validID(listingID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 listing id가 필요합니다"})
|
||||
return apperror.BadRequest("유효한 listing id가 필요합니다")
|
||||
}
|
||||
result, err := h.svc.GetListing(listingID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
|
||||
return chainError("마켓 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
@@ -244,14 +286,14 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
|
||||
Amount uint64 `json:"amount"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.To) || req.Amount == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"})
|
||||
return apperror.BadRequest("to와 amount는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.Transfer(userID, req.To, req.Amount)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err)
|
||||
return chainError("전송에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -280,14 +322,14 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
|
||||
To string `json:"to"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.AssetID) || !validID(req.To) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"})
|
||||
return apperror.BadRequest("assetId와 to는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err)
|
||||
return chainError("에셋 전송에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -316,14 +358,14 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
|
||||
Price uint64 `json:"price"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.AssetID) || req.Price == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"})
|
||||
return apperror.BadRequest("assetId와 price는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err)
|
||||
return chainError("마켓 등록에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -351,14 +393,14 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
|
||||
ListingID string `json:"listingId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.ListingID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
|
||||
return apperror.BadRequest("listingId는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err)
|
||||
return chainError("마켓 구매에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -386,14 +428,14 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
|
||||
ListingID string `json:"listingId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.ListingID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"})
|
||||
return apperror.BadRequest("listingId는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.CancelListing(userID, req.ListingID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err)
|
||||
return chainError("마켓 취소에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -422,14 +464,14 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
|
||||
Slot string `json:"slot"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.AssetID) || !validID(req.Slot) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"})
|
||||
return apperror.BadRequest("assetId와 slot은 필수입니다")
|
||||
}
|
||||
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err)
|
||||
return chainError("장착에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -457,14 +499,14 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
|
||||
AssetID string `json:"assetId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.AssetID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"})
|
||||
return apperror.BadRequest("assetId는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.UnequipItem(userID, req.AssetID)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err)
|
||||
return chainError("장착 해제에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -493,14 +535,14 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.TemplateID) || !validID(req.OwnerPubKey) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"})
|
||||
return apperror.BadRequest("templateId와 ownerPubKey는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
|
||||
return chainError("에셋 발행에 실패했습니다", err)
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(result)
|
||||
}
|
||||
@@ -527,14 +569,14 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
|
||||
Assets []core.MintAssetPayload `json:"assets"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.RecipientPubKey) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"})
|
||||
return apperror.BadRequest("recipientPubKey는 필수입니다")
|
||||
}
|
||||
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
|
||||
return chainError("보상 지급에 실패했습니다", err)
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(result)
|
||||
}
|
||||
@@ -562,14 +604,14 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
|
||||
Tradeable bool `json:"tradeable"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.ID) || !validID(req.Name) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"})
|
||||
return apperror.BadRequest("id와 name은 필수입니다")
|
||||
}
|
||||
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err)
|
||||
return chainError("템플릿 등록에 실패했습니다", err)
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(result)
|
||||
}
|
||||
@@ -596,14 +638,14 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
|
||||
Assets []core.MintAssetPayload `json:"assets"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.Username) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
||||
return apperror.BadRequest("username은 필수입니다")
|
||||
}
|
||||
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
|
||||
return chainError("보상 지급에 실패했습니다", err)
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(result)
|
||||
}
|
||||
@@ -628,14 +670,14 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
return apperror.ErrBadRequest
|
||||
}
|
||||
if !validID(req.TemplateID) || !validID(req.Username) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"})
|
||||
return apperror.BadRequest("templateId와 username은 필수입니다")
|
||||
}
|
||||
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
|
||||
return chainError("에셋 발행에 실패했습니다", err)
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(result)
|
||||
}
|
||||
@@ -654,11 +696,11 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
|
||||
func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
|
||||
username := c.Query("username")
|
||||
if !validID(username) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
||||
return apperror.BadRequest("username은 필수입니다")
|
||||
}
|
||||
result, err := h.svc.GetBalanceByUsername(username)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
|
||||
return chainError("잔액 조회에 실패했습니다", err)
|
||||
}
|
||||
return c.JSON(result)
|
||||
}
|
||||
@@ -679,12 +721,12 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
|
||||
func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
|
||||
username := c.Query("username")
|
||||
if !validID(username) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
||||
return apperror.BadRequest("username은 필수입니다")
|
||||
}
|
||||
offset, limit := parsePagination(c)
|
||||
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
|
||||
return chainError("에셋 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
@@ -704,11 +746,11 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
|
||||
func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
|
||||
username := c.Query("username")
|
||||
if !validID(username) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
|
||||
return apperror.BadRequest("username은 필수입니다")
|
||||
}
|
||||
result, err := h.svc.GetInventoryByUsername(username)
|
||||
if err != nil {
|
||||
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
|
||||
return chainError("인벤토리 조회에 실패했습니다", err)
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Send(result)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -232,3 +232,40 @@ func TestResolveUsername_NoResolver(t *testing.T) {
|
||||
t.Error("resolveUsername should fail when userResolver is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyTxError(t *testing.T) {
|
||||
tests := []struct {
|
||||
chainMsg string
|
||||
want string
|
||||
}{
|
||||
{"insufficient balance: have 0 need 100: insufficient balance", "잔액이 부족합니다"},
|
||||
{"only the asset owner can list it: unauthorized", "권한이 없습니다"},
|
||||
{"session \"abc\" already exists: already exists", "이미 존재합니다"},
|
||||
{"asset \"xyz\" not found: not found", "리소스를 찾을 수 없습니다"},
|
||||
{"asset is not tradeable", "거래할 수 없는 아이템입니다"},
|
||||
{"asset \"a\" is equipped; unequip it before listing", "장착 중인 아이템입니다"},
|
||||
{"asset \"a\" is already listed (listing x): already exists", "이미 마켓에 등록된 아이템입니다"},
|
||||
{"listing \"x\" is not active", "활성 상태가 아닌 매물입니다"},
|
||||
{"session \"x\" is not open (status=closed)", "진행 중이 아닌 세션입니다"},
|
||||
{"invalid nonce: expected 5 got 3: invalid nonce", "트랜잭션 처리 중 오류가 발생했습니다. 다시 시도해주세요"},
|
||||
{"some unknown error", "블록체인 트랜잭션이 실패했습니다"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.chainMsg, func(t *testing.T) {
|
||||
got := classifyTxError(tt.chainMsg)
|
||||
if got != tt.want {
|
||||
t.Errorf("classifyTxError(%q) = %q, want %q", tt.chainMsg, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTxError_Error(t *testing.T) {
|
||||
err := &TxError{TxID: "abc123", Message: "insufficient balance"}
|
||||
got := err.Error()
|
||||
want := "transaction abc123 failed: insufficient balance"
|
||||
if got != want {
|
||||
t.Errorf("TxError.Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user