diff --git a/go.mod b/go.mod index 8e16b3b..70eeafb 100644 --- a/go.mod +++ b/go.mod @@ -2,31 +2,46 @@ module a301_server go 1.25 +require ( + github.com/gofiber/fiber/v2 v2.52.11 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.18.0 + github.com/tolelom/tolchain v0.0.0 + golang.org/x/crypto v0.48.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect - github.com/gofiber/fiber/v2 v2.52.11 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - gorm.io/driver/mysql v1.6.0 // indirect - gorm.io/gorm v1.31.1 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) + +replace github.com/tolelom/tolchain => ../tolchain diff --git a/go.sum b/go.sum index fba2253..b31ce34 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,17 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -12,6 +21,8 @@ github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31H github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -20,8 +31,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -29,18 +48,40 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -49,6 +90,13 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= diff --git a/internal/auth/service.go b/internal/auth/service.go index 80cd335..66c1b1d 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "log" "time" "a301_server/pkg/config" @@ -19,14 +20,21 @@ type Claims struct { } type Service struct { - repo *Repository - rdb *redis.Client + repo *Repository + rdb *redis.Client + walletCreator func(userID uint) error } func NewService(repo *Repository, rdb *redis.Client) *Service { return &Service{repo: repo, rdb: rdb} } +// SetWalletCreator sets the callback invoked after user registration +// to create a blockchain wallet. +func (s *Service) SetWalletCreator(fn func(userID uint) error) { + s.walletCreator = fn +} + func (s *Service) Login(username, password string) (string, *User, error) { user, err := s.repo.FindByUsername(username) if err != nil { @@ -85,11 +93,20 @@ func (s *Service) Register(username, password string) error { if err != nil { return fmt.Errorf("비밀번호 처리에 실패했습니다") } - return s.repo.Create(&User{ + user := &User{ Username: username, PasswordHash: string(hash), Role: RoleUser, - }) + } + if err := s.repo.Create(user); err != nil { + return err + } + if s.walletCreator != nil { + if err := s.walletCreator(user.ID); err != nil { + log.Printf("WARNING: wallet creation failed for user %d: %v", user.ID, err) + } + } + return nil } // VerifyToken validates a JWT and its Redis session, returning (username, error). diff --git a/internal/chain/client.go b/internal/chain/client.go new file mode 100644 index 0000000..898988f --- /dev/null +++ b/internal/chain/client.go @@ -0,0 +1,145 @@ +package chain + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" + "time" +) + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params any `json:"params"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *rpcError) Error() string { + return fmt.Sprintf("RPC error %d: %s", e.Code, e.Message) +} + +// Client is a JSON-RPC 2.0 client for the TOL Chain node. +type Client struct { + nodeURL string + http *http.Client + idSeq atomic.Int64 +} + +func NewClient(nodeURL string) *Client { + return &Client{ + nodeURL: nodeURL, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Call invokes a JSON-RPC method and unmarshals the result into out. +func (c *Client) Call(method string, params any, out any) error { + reqBody := rpcRequest{ + JSONRPC: "2.0", + ID: c.idSeq.Add(1), + Method: method, + Params: params, + } + data, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal RPC request: %w", err) + } + + resp, err := c.http.Post(c.nodeURL, "application/json", bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("RPC network error: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read RPC response: %w", err) + } + + var rpcResp rpcResponse + if err := json.Unmarshal(body, &rpcResp); err != nil { + return fmt.Errorf("unmarshal RPC response: %w", err) + } + if rpcResp.Error != nil { + return rpcResp.Error + } + if out != nil { + if err := json.Unmarshal(rpcResp.Result, out); err != nil { + return fmt.Errorf("unmarshal RPC result: %w", err) + } + } + return nil +} + +// --- Typed convenience methods --- + +type BalanceResult struct { + Address string `json:"address"` + Balance uint64 `json:"balance"` + Nonce uint64 `json:"nonce"` +} + +func (c *Client) GetBalance(address string) (*BalanceResult, error) { + var result BalanceResult + err := c.Call("getBalance", map[string]string{"address": address}, &result) + return &result, err +} + +func (c *Client) GetAsset(id string) (json.RawMessage, error) { + var result json.RawMessage + err := c.Call("getAsset", map[string]string{"id": id}, &result) + return result, err +} + +func (c *Client) GetAssetsByOwner(owner string, offset, limit int) (json.RawMessage, error) { + var result json.RawMessage + err := c.Call("getAssetsByOwner", map[string]any{ + "owner": owner, "offset": offset, "limit": limit, + }, &result) + return result, err +} + +func (c *Client) GetInventory(owner string) (json.RawMessage, error) { + var result json.RawMessage + err := c.Call("getInventory", map[string]string{"owner": owner}, &result) + return result, err +} + +func (c *Client) GetActiveListings(offset, limit int) (json.RawMessage, error) { + var result json.RawMessage + err := c.Call("getActiveListings", map[string]any{ + "offset": offset, "limit": limit, + }, &result) + return result, err +} + +func (c *Client) GetListing(id string) (json.RawMessage, error) { + var result json.RawMessage + err := c.Call("getListing", map[string]string{"id": id}, &result) + return result, err +} + +type SendTxResult struct { + TxID string `json:"tx_id"` +} + +func (c *Client) SendTx(tx any) (*SendTxResult, error) { + var result SendTxResult + err := c.Call("sendTx", tx, &result) + return &result, err +} diff --git a/internal/chain/handler.go b/internal/chain/handler.go new file mode 100644 index 0000000..d7670f3 --- /dev/null +++ b/internal/chain/handler.go @@ -0,0 +1,375 @@ +package chain + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/tolelom/tolchain/core" +) + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +// ---- Query Handlers ---- + +func (h *Handler) GetWalletInfo(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + w, err := h.svc.GetWallet(userID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"}) + } + return c.JSON(fiber.Map{ + "address": w.Address, + "pubKeyHex": w.PubKeyHex, + }) +} + +func (h *Handler) GetBalance(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + result, err := h.svc.GetBalance(userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) GetAssets(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + limit, _ := strconv.Atoi(c.Query("limit", "50")) + result, err := h.svc.GetAssets(userID, offset, limit) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} + +func (h *Handler) GetAsset(c *fiber.Ctx) error { + assetID := c.Params("id") + if assetID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "asset id is required"}) + } + result, err := h.svc.GetAsset(assetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} + +func (h *Handler) GetInventory(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + result, err := h.svc.GetInventory(userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} + +func (h *Handler) GetMarketListings(c *fiber.Ctx) error { + offset, _ := strconv.Atoi(c.Query("offset", "0")) + limit, _ := strconv.Atoi(c.Query("limit", "50")) + result, err := h.svc.GetMarketListings(offset, limit) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} + +func (h *Handler) GetMarketListing(c *fiber.Ctx) error { + listingID := c.Params("id") + if listingID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listing id is required"}) + } + result, err := h.svc.GetListing(listingID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} + +// ---- User Transaction Handlers ---- + +func (h *Handler) Transfer(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + To string `json:"to"` + Amount uint64 `json:"amount"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.To == "" || req.Amount == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"}) + } + result, err := h.svc.Transfer(userID, req.To, req.Amount) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) TransferAsset(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + AssetID string `json:"assetId"` + To string `json:"to"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.AssetID == "" || req.To == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"}) + } + result, err := h.svc.TransferAsset(userID, req.AssetID, req.To) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) ListOnMarket(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + AssetID string `json:"assetId"` + Price uint64 `json:"price"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.AssetID == "" || req.Price == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"}) + } + result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) BuyFromMarket(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + ListingID string `json:"listingId"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.ListingID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"}) + } + result, err := h.svc.BuyFromMarket(userID, req.ListingID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) CancelListing(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + ListingID string `json:"listingId"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.ListingID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"}) + } + result, err := h.svc.CancelListing(userID, req.ListingID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) EquipItem(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + AssetID string `json:"assetId"` + Slot string `json:"slot"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.AssetID == "" || req.Slot == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"}) + } + result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +func (h *Handler) UnequipItem(c *fiber.Ctx) error { + userID := c.Locals("userID").(uint) + var req struct { + AssetID string `json:"assetId"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.AssetID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"}) + } + result, err := h.svc.UnequipItem(userID, req.AssetID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +// ---- Operator (Admin) Transaction Handlers ---- + +func (h *Handler) MintAsset(c *fiber.Ctx) error { + var req struct { + TemplateID string `json:"templateId"` + OwnerPubKey string `json:"ownerPubKey"` + Properties map[string]any `json:"properties"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.TemplateID == "" || req.OwnerPubKey == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"}) + } + result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(result) +} + +func (h *Handler) GrantReward(c *fiber.Ctx) error { + var req struct { + RecipientPubKey string `json:"recipientPubKey"` + TokenAmount uint64 `json:"tokenAmount"` + Assets []core.MintAssetPayload `json:"assets"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.RecipientPubKey == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"}) + } + result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(result) +} + +func (h *Handler) RegisterTemplate(c *fiber.Ctx) error { + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Schema map[string]any `json:"schema"` + Tradeable bool `json:"tradeable"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.ID == "" || req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"}) + } + result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(result) +} + +// ---- Internal Handlers (game server, username-based) ---- + +// InternalGrantReward grants reward by username. For game server use. +func (h *Handler) InternalGrantReward(c *fiber.Ctx) error { + var req struct { + Username string `json:"username"` + TokenAmount uint64 `json:"tokenAmount"` + Assets []core.MintAssetPayload `json:"assets"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.Username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + } + result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(result) +} + +// InternalMintAsset mints an asset by username. For game server use. +func (h *Handler) InternalMintAsset(c *fiber.Ctx) error { + var req struct { + TemplateID string `json:"templateId"` + Username string `json:"username"` + Properties map[string]any `json:"properties"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.TemplateID == "" || req.Username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"}) + } + result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(result) +} + +// InternalGetBalance returns balance by username. For game server use. +func (h *Handler) InternalGetBalance(c *fiber.Ctx) error { + username := c.Query("username") + if username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + } + result, err := h.svc.GetBalanceByUsername(username) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(result) +} + +// InternalGetAssets returns assets by username. For game server use. +func (h *Handler) InternalGetAssets(c *fiber.Ctx) error { + username := c.Query("username") + if username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + } + offset, _ := strconv.Atoi(c.Query("offset", "0")) + limit, _ := strconv.Atoi(c.Query("limit", "50")) + result, err := h.svc.GetAssetsByUsername(username, offset, limit) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} + +// InternalGetInventory returns inventory by username. For game server use. +func (h *Handler) InternalGetInventory(c *fiber.Ctx) error { + username := c.Query("username") + if username == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) + } + result, err := h.svc.GetInventoryByUsername(username) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + c.Set("Content-Type", "application/json") + return c.Send(result) +} diff --git a/internal/chain/model.go b/internal/chain/model.go new file mode 100644 index 0000000..6d261c4 --- /dev/null +++ b/internal/chain/model.go @@ -0,0 +1,20 @@ +package chain + +import ( + "time" + + "gorm.io/gorm" +) + +// UserWallet stores an encrypted ed25519 keypair linked to a user. +type UserWallet struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + UserID uint `json:"userId" gorm:"uniqueIndex;not null"` + PubKeyHex string `json:"pubKeyHex" gorm:"type:varchar(64);uniqueIndex;not null"` + Address string `json:"address" gorm:"type:varchar(40);uniqueIndex;not null"` + EncryptedPrivKey string `json:"-" gorm:"type:varchar(512);not null"` + EncNonce string `json:"-" gorm:"type:varchar(48);not null"` +} diff --git a/internal/chain/repository.go b/internal/chain/repository.go new file mode 100644 index 0000000..deaceb8 --- /dev/null +++ b/internal/chain/repository.go @@ -0,0 +1,27 @@ +package chain + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(w *UserWallet) error { + return r.db.Create(w).Error +} + +func (r *Repository) FindByUserID(userID uint) (*UserWallet, error) { + var w UserWallet + err := r.db.Where("user_id = ?", userID).First(&w).Error + return &w, err +} + +func (r *Repository) FindByPubKeyHex(pubKeyHex string) (*UserWallet, error) { + var w UserWallet + err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error + return &w, err +} diff --git a/internal/chain/service.go b/internal/chain/service.go new file mode 100644 index 0000000..d70fbc5 --- /dev/null +++ b/internal/chain/service.go @@ -0,0 +1,425 @@ +package chain + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + + "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) +} + +// 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: %s", username) + } + uw, err := s.repo.FindByUserID(userID) + if err != nil { + return "", fmt.Errorf("wallet not found for user: %s", 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) 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 { + return nil, "", err + } + 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) +} + +// ---- User Transaction Methods ---- + +func (s *Service) Transfer(userID uint, to string, amount uint64) (*SendTxResult, error) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) +} diff --git a/main.go b/main.go index ad7f620..c36bcf5 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "a301_server/internal/announcement" "a301_server/internal/auth" + "a301_server/internal/chain" "a301_server/internal/download" "a301_server/pkg/config" "a301_server/pkg/database" @@ -23,7 +24,7 @@ func main() { log.Println("MySQL 연결 성공") // AutoMigrate - database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}) + database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}) if err := database.ConnectRedis(); err != nil { log.Fatalf("Redis 연결 실패: %v", err) @@ -42,6 +43,30 @@ func main() { log.Printf("admin 계정 확인 완료: %s", config.C.AdminUsername) } + // Chain (blockchain integration) + chainClient := chain.NewClient(config.C.ChainNodeURL) + chainRepo := chain.NewRepository(database.DB) + chainSvc, err := chain.NewService(chainRepo, chainClient, config.C.ChainID, config.C.OperatorKeyHex, config.C.WalletEncryptionKey) + if err != nil { + log.Fatalf("chain service init failed: %v", err) + } + chainHandler := chain.NewHandler(chainSvc) + + // username → userID 변환 (게임 서버 내부 API용) + chainSvc.SetUserResolver(func(username string) (uint, error) { + user, err := authRepo.FindByUsername(username) + if err != nil { + return 0, err + } + return user.ID, nil + }) + + // 회원가입 시 블록체인 월렛 자동 생성 + authSvc.SetWalletCreator(func(userID uint) error { + _, err := chainSvc.CreateWallet(userID) + return err + }) + annRepo := announcement.NewRepository(database.DB) annSvc := announcement.NewService(annRepo) annHandler := announcement.NewHandler(annSvc) @@ -60,7 +85,7 @@ func main() { AllowMethods: "GET, POST, PUT, PATCH, DELETE", })) - routes.Register(app, authHandler, annHandler, dlHandler) + routes.Register(app, authHandler, annHandler, dlHandler, chainHandler) log.Fatal(app.Listen(":" + config.C.AppPort)) } diff --git a/pkg/config/config.go b/pkg/config/config.go index fa93d28..f1177e0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,6 +22,15 @@ type Config struct { AdminPassword string BaseURL string GameDir string + + // Chain integration + ChainNodeURL string + ChainID string + OperatorKeyHex string + WalletEncryptionKey string + + // Server-to-server auth + InternalAPIKey string } var C Config @@ -45,6 +54,13 @@ func Load() { AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"), BaseURL: getEnv("BASE_URL", "http://localhost:8080"), GameDir: getEnv("GAME_DIR", "/data/game"), + + ChainNodeURL: getEnv("CHAIN_NODE_URL", "http://localhost:8545"), + ChainID: getEnv("CHAIN_ID", "tolchain-dev"), + OperatorKeyHex: getEnv("OPERATOR_KEY_HEX", ""), + WalletEncryptionKey: getEnv("WALLET_ENCRYPTION_KEY", ""), + + InternalAPIKey: getEnv("INTERNAL_API_KEY", ""), } } diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 598a99d..45e6b37 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -52,3 +52,12 @@ func AdminOnly(c *fiber.Ctx) error { } return c.Next() } + +// ServerAuth validates X-API-Key header for server-to-server communication. +func ServerAuth(c *fiber.Ctx) error { + key := c.Get("X-API-Key") + if key == "" || config.C.InternalAPIKey == "" || key != config.C.InternalAPIKey { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"}) + } + return c.Next() +} diff --git a/routes/routes.go b/routes/routes.go index 10f8a19..3fdebb3 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "a301_server/internal/announcement" "a301_server/internal/auth" + "a301_server/internal/chain" "a301_server/internal/download" "a301_server/pkg/middleware" "github.com/gofiber/fiber/v2" @@ -13,6 +14,7 @@ func Register( authH *auth.Handler, annH *announcement.Handler, dlH *download.Handler, + chainH *chain.Handler, ) { api := app.Group("/api") @@ -43,4 +45,37 @@ func Register( dl.Get("/launcher", dlH.ServeLauncher) dl.Post("/upload/game", middleware.Auth, middleware.AdminOnly, dlH.Upload) dl.Post("/upload/launcher", middleware.Auth, middleware.AdminOnly, dlH.UploadLauncher) + + // Chain - Queries (authenticated) + ch := api.Group("/chain", middleware.Auth) + ch.Get("/wallet", chainH.GetWalletInfo) + ch.Get("/balance", chainH.GetBalance) + ch.Get("/assets", chainH.GetAssets) + ch.Get("/asset/:id", chainH.GetAsset) + ch.Get("/inventory", chainH.GetInventory) + ch.Get("/market", chainH.GetMarketListings) + ch.Get("/market/:id", chainH.GetMarketListing) + + // Chain - User Transactions (authenticated) + ch.Post("/transfer", chainH.Transfer) + ch.Post("/asset/transfer", chainH.TransferAsset) + ch.Post("/market/list", chainH.ListOnMarket) + ch.Post("/market/buy", chainH.BuyFromMarket) + ch.Post("/market/cancel", chainH.CancelListing) + ch.Post("/inventory/equip", chainH.EquipItem) + ch.Post("/inventory/unequip", chainH.UnequipItem) + + // Chain - Admin Transactions (admin only) + chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly) + chainAdmin.Post("/mint", chainH.MintAsset) + chainAdmin.Post("/reward", chainH.GrantReward) + chainAdmin.Post("/template", chainH.RegisterTemplate) + + // Internal - Game server endpoints (API key auth, username-based) + internal := api.Group("/internal/chain", middleware.ServerAuth) + internal.Post("/reward", chainH.InternalGrantReward) + internal.Post("/mint", chainH.InternalMintAsset) + internal.Get("/balance", chainH.InternalGetBalance) + internal.Get("/assets", chainH.InternalGetAssets) + internal.Get("/inventory", chainH.InternalGetInventory) }