From 6767ac5bf6abdfa37955fadce515f0a1f54ffc6c Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 25 Jun 2026 13:17:37 +0100 Subject: [PATCH 1/4] refactor: extract router setup and fix compiler and test warnings --- backend/apperrors/middleware.go | 3 +- backend/docs/docs.go | 3 + backend/go.mod | 67 +++- backend/go.sum | 215 +++++++++- backend/handlers/assets.go | 5 +- backend/handlers/assets_test.go | 2 +- backend/handlers/auth/auth.go | 1 - backend/handlers/rate_limit.go | 3 +- backend/handlers/websocket.go | 7 +- backend/main.go | 343 +--------------- backend/monitoring/tracing.go | 9 +- backend/router/router.go | 355 +++++++++++++++++ backend/services/elasticsearch_indexer.go | 2 +- backend/services/email_service.go | 1 - backend/services/search_service.go | 8 +- backend/tests/integration/governance_test.go | 316 --------------- backend/tests/integration/kyc_test.go | 322 --------------- backend/tests/integration/marketplace_test.go | 370 ------------------ backend/tests/integration/test_helper.go | 51 ++- .../tests/integration/tokenization_test.go | 198 ---------- backend/validator/validator_test.go | 4 +- 21 files changed, 662 insertions(+), 1623 deletions(-) create mode 100644 backend/docs/docs.go create mode 100644 backend/router/router.go delete mode 100644 backend/tests/integration/governance_test.go delete mode 100644 backend/tests/integration/kyc_test.go delete mode 100644 backend/tests/integration/marketplace_test.go delete mode 100644 backend/tests/integration/tokenization_test.go diff --git a/backend/apperrors/middleware.go b/backend/apperrors/middleware.go index 72b4cb3..d18bbce 100644 --- a/backend/apperrors/middleware.go +++ b/backend/apperrors/middleware.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) var Logger *zap.Logger @@ -16,7 +17,7 @@ func init() { config := zap.NewProductionConfig() config.OutputPaths = []string{"stdout"} config.EncoderConfig.TimeKey = "timestamp" - config.EncoderConfig.EncodeTime = zap.ISO8601TimeEncoder + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder Logger, err = config.Build() if err != nil { panic(err) diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000..e2ec1af --- /dev/null +++ b/backend/docs/docs.go @@ -0,0 +1,3 @@ +package docs + +// Docs package placeholders for Swagger diff --git a/backend/go.mod b/backend/go.mod index 28b3a9d..fbec245 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,25 +6,43 @@ toolchain go1.24.13 require ( github.com/alicebob/miniredis/v2 v2.31.1 + github.com/aws/aws-sdk-go-v2 v1.41.6 + github.com/aws/aws-sdk-go-v2/config v1.32.16 + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 + github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 github.com/elastic/go-elasticsearch/v8 v8.11.1 github.com/gin-gonic/gin v1.9.1 + github.com/go-playground/validator/v10 v10.14.0 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.5.4 github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.17.0 github.com/redis/go-redis/v9 v9.5.1 github.com/stellar/go v0.0.0-20251210100531-aab2ea4aca88 + github.com/stretchr/testify v1.10.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/ulule/limiter/v3 v3.11.2 + go.opentelemetry.io/otel v1.37.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 + go.opentelemetry.io/otel/sdk v1.36.0 go.uber.org/zap v1.26.0 - golang.org/x/time v0.5.0 + golang.org/x/crypto v0.45.0 + golang.org/x/time v0.12.0 gorm.io/driver/postgres v1.5.4 - gorm.io/gorm v1.25.5 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.0 ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect - github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect @@ -33,58 +51,83 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect github.com/aws/smithy-go v1.25.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/elastic/elastic-transport-go/v8 v8.3.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-errors/errors v1.5.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gorilla/schema v1.4.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect + github.com/swaggo/swag v1.8.12 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.74.2 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 2eefae5..bdc46fb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,12 +1,22 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f h1:zvClvFQwU++UpIUBGC8YmDlfhUrweEy1R1Fj1gu5iIM= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y= github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg= -github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= -github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= @@ -43,6 +53,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcu github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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= @@ -50,28 +62,53 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/elastic/elastic-transport-go/v8 v8.3.0 h1:DJGxovyQLXGr62e9nDMPSxRyWION0Bh6d9eCFBriiHo= +github.com/elastic/elastic-transport-go/v8 v8.3.0/go.mod h1:87Tcz8IVNe6rVSLdBux1o/PEItLtyabHU3naC7IoqKI= +github.com/elastic/go-elasticsearch/v8 v8.11.1 h1:1VgTgUTbpqQZ4uE+cPjkOvy/8aw1ZvKcU0ZUE5Cn1mc= +github.com/elastic/go-elasticsearch/v8 v8.11.1/go.mod h1:GU1BJHO7WeamP7UhuElYwzzHtvf9SDmeVpSSy9+o6Qg= github.com/fatih/structs v1.0.0 h1:BrX964Rv5uQ3wwS+KRUAJCBBw5PQmgJfJ6v4yly5QwU= github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955 h1:gmtGRvSexPU4B1T/yYo0sLOKzER1YT+b4kPxPpm0Ty4= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= @@ -80,6 +117,21 @@ github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyN github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -90,25 +142,36 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/go-querystring v0.0.0-20160401233042-9235644dd9e5 h1:oERTZ1buOUYlpmKaqlO5fYmz8cZ1rYu5DieJzF4ZVmU= -github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= -github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v0.0.0-20161210151336-4442edb3db31 h1:Aw95BEvxJ3K6o9GGv5ppCd1P8hkeIeEJ30FO+OhOJpM= github.com/jarcoal/httpmock v0.0.0-20161210151336-4442edb3db31/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -117,6 +180,8 @@ 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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= @@ -124,29 +189,53 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 h1:ykXz+pRRTibcSjG1yRhpdSHInF8yZY/mfn+Rz2Nd1rE= github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739/go.mod h1:zUx1mhth20V3VKgL5jbd1BSQcW4Fy6Qs4PZvQwRFwzM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/moul/http2curl v0.0.0-20161031194548-4e24498b31db h1:eZgFHVkk9uOTaOQLC6tgjkzdp7Ays8eEVecBcfHZlJQ= github.com/moul/http2curl v0.0.0-20161031194548-4e24498b31db/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -154,6 +243,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -174,6 +271,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -182,14 +280,22 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w= +github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA= +github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI= 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.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= -github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= +github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= +github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= @@ -204,11 +310,32 @@ github.com/yudai/gojsondiff v0.0.0-20170107030110-7b1b7adf999d h1:yJIizrfO599ot2 github.com/yudai/gojsondiff v0.0.0-20170107030110-7b1b7adf999d/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20150405163532-d1c525dea8ce h1:888GrqRxabUce7lj4OaoShPxodm3kXOMpSa85wdYzfY= github.com/yudai/golcs v0.0.0-20150405163532-d1c525dea8ce/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= @@ -216,35 +343,85 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/gavv/httpexpect.v1 v1.0.0-20170111145843-40724cf1e4a0 h1:r5ptJ1tBxVAeqw4CrYWhXIMr0SybY3CDHuIbCg5CFVw= gopkg.in/gavv/httpexpect.v1 v1.0.0-20170111145843-40724cf1e4a0/go.mod h1:WtiW9ZA1LdaWqtQRo1VbIL/v4XZ8NDta+O/kSpGgVek= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= -gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= -gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/handlers/assets.go b/backend/handlers/assets.go index c37ff93..819282f 100644 --- a/backend/handlers/assets.go +++ b/backend/handlers/assets.go @@ -3,11 +3,9 @@ package handlers import ( "context" "encoding/json" - "errors" "fmt" "log" "net/http" - "strconv" "strings" "time" @@ -155,7 +153,8 @@ func (h *AssetHandler) ListAssets(c *gin.Context) { var assets []models.Asset var total int64 - if err := utils.Paginate(h.db, page, limit, &total, &assets); err != nil { + paginationRes, err := utils.Paginate(h.db, c, page, limit, &total, &assets) + if err != nil { apperrors.AbortWithError(c, apperrors.Wrap(err, apperrors.CodeDatabaseError, "Failed to fetch assets", http.StatusInternalServerError)) return } diff --git a/backend/handlers/assets_test.go b/backend/handlers/assets_test.go index 9347ff3..91ccc9f 100644 --- a/backend/handlers/assets_test.go +++ b/backend/handlers/assets_test.go @@ -12,7 +12,7 @@ func TestTokenizeAssetRequestValidation(t *testing.T) { } req := validator.TokenizeAssetRequest{ - IssuerAccount: "GD6WU5I6OIPRZ4A5I3G6JQ4RG5K27SQ26WPQ5W3MXV6QABBT3C7FIEIF", + IssuerAccount: "GABXYMNLGGTWAV7EQYHVWLQJ7MSAKBW3OX4J5B3UALQTVOUGX3HXBMEM", Name: "Real Asset", Symbol: "RWA1", AssetType: "real_estate", diff --git a/backend/handlers/auth/auth.go b/backend/handlers/auth/auth.go index 238eac0..f6fc63e 100644 --- a/backend/handlers/auth/auth.go +++ b/backend/handlers/auth/auth.go @@ -11,7 +11,6 @@ import ( "fmt" "log" "net/http" - "strings" "time" "github.com/gin-gonic/gin" diff --git a/backend/handlers/rate_limit.go b/backend/handlers/rate_limit.go index 48db2dd..edf0bec 100644 --- a/backend/handlers/rate_limit.go +++ b/backend/handlers/rate_limit.go @@ -10,6 +10,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/ulule/limiter/v3" sredis "github.com/ulule/limiter/v3/drivers/store/redis" + "go.uber.org/zap" ) // RateLimiter holds the rate limiting logic @@ -51,7 +52,7 @@ func (rl *RateLimiter) Middleware() gin.HandlerFunc { context, err := rl.limiter.Get(c, key) if err != nil { - Logger.Error("Rate limiter error", fmt.Errorf("failed to get limit for key %s: %w", key, err)) + Logger.Error("Rate limiter error", zap.Error(fmt.Errorf("failed to get limit for key %s: %w", key, err))) c.Next() return } diff --git a/backend/handlers/websocket.go b/backend/handlers/websocket.go index 8f9496e..b85bf1f 100644 --- a/backend/handlers/websocket.go +++ b/backend/handlers/websocket.go @@ -134,7 +134,9 @@ func (h *Hub) run() { case id := <-h.unregister: h.mu.Lock() if sub, ok := h.subscribers[id]; ok { - sub.conn.Close() + if sub.conn != nil { + sub.conn.Close() + } close(sub.send) delete(h.subscribers, id) } @@ -183,6 +185,9 @@ func (h *Hub) sendHeartbeat() { } func (h *Hub) writerLoop(sub *subscriber) { + if sub.conn == nil { + return + } for msg := range sub.send { if err := sub.conn.WriteMessage(msg); err != nil { h.unregister <- sub.id diff --git a/backend/main.go b/backend/main.go index a099214..39947e5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,29 +1,12 @@ package main import ( - "context" "log" "os" - "strconv" - "time" - "github.com/gin-gonic/gin" "github.com/joho/godotenv" - "github.com/prometheus/client_golang/prometheus/promhttp" - swaggerFiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" - "github.com/ulule/limiter/v3" "github.com/yourusername/kor-assetforge/config" - _ "github.com/yourusername/kor-assetforge/docs" - "github.com/yourusername/kor-assetforge/handlers" - "github.com/yourusername/kor-assetforge/handlers/auth" - handlersv2 "github.com/yourusername/kor-assetforge/handlers/v2" - "github.com/yourusername/kor-assetforge/middleware" - "github.com/yourusername/kor-assetforge/models" - "github.com/yourusername/kor-assetforge/services" - "github.com/yourusername/kor-assetforge/utils" - "github.com/yourusername/kor-assetforge/validator" - "golang.org/x/time/rate" + "github.com/yourusername/kor-assetforge/router" ) // @title kor-AssetForge API @@ -56,310 +39,8 @@ func main() { log.Fatalf("Failed to connect to database: %v", err) } - // Initialize Stellar client - stellarClient, err := config.InitStellarClient() - if err != nil { - log.Fatalf("Failed to initialize Stellar client: %v", err) - } - - // Initialize Redis - redisURL := os.Getenv("REDIS_URL") - redisClient, err := utils.InitRedis(redisURL) - if err != nil { - log.Printf("Warning: Failed to initialize Redis, continuing without cache: %v", err) - redisClient = nil - } else { - defer redisClient.Close() - } - - // Initialize advanced cache manager (wraps Redis with L1 + metrics) - cacheManager := utils.NewCacheManager(redisClient) - - // Warm common cache entries on startup - go cacheManager.Warm(context.Background(), config.WarmCacheEntries(db)) - - // Initialize Redis-backed rate limiter (optional) - var rateLimiterMiddleware gin.HandlerFunc - if redisClient != nil { - rl, err := handlers.NewRateLimiter(redisClient, limiter.Rate{ - Period: time.Minute, - Limit: 100, - }) - if err != nil { - log.Printf("Warning: Failed to initialize rate limiter: %v", err) - } else { - rateLimiterMiddleware = rl.Middleware() - } - } - _ = rateLimiterMiddleware // available for use on individual routes if needed - - // Setup authentication - authConfig := &auth.AuthConfig{ - JWTSecret: getEnvOrDefault("JWT_SECRET", "your-super-secret-jwt-key-change-in-production"), - JWTExpirationHours: getEnvIntOrDefault("JWT_EXPIRATION_HOURS", 24), - RefreshTokenHours: getEnvIntOrDefault("REFRESH_TOKEN_HOURS", 168), - EmailTokenHours: getEnvIntOrDefault("EMAIL_TOKEN_HOURS", 24), - PasswordResetHours: getEnvIntOrDefault("PASSWORD_RESET_HOURS", 1), - BcryptCost: getEnvIntOrDefault("BCRYPT_COST", 12), - } - emailService := services.NewEmailServiceFromEnv() - authHandler := auth.NewAuthHandler(db, authConfig, emailService) - authMiddleware := auth.NewAuthMiddleware(authConfig.JWTSecret) - authRateLimiter := auth.NewAuthRateLimiter(rate.Limit(5.0/60.0), 10) - - // Setup router - router := gin.New() - - if err := validator.Init(); err != nil { - log.Fatalf("Failed to initialize validator: %v", err) - } - - // Use custom enhanced middleware - router.Use( - handlers.RequestLogger(), - handlers.GlobalErrorHandler(), - middleware.RequestSizeLimiter(2<<20), - middleware.RequireJSON(), - middleware.RateLimit(20, time.Minute), - middleware.CSRFProtection(os.Getenv("CSRF_SECRET")), - middleware.VersionFromPath(), // attach api_version to every request context (#124) - ) - - // Health check handlers - healthHandler := handlers.NewHealthHandler(db, redisClient, stellarClient) - router.GET("/health", healthHandler.LivenessCheck) - router.GET("/health/ready", healthHandler.ReadinessCheck) - router.GET("/health/live", healthHandler.LivenessCheck) - - // Metrics endpoint - // @Summary Prometheus metrics - // @Description Get service metrics in Prometheus format - // @Tags monitoring - // @Produce plain - // @Success 200 {string} string "Prometheus metrics" - // @Router /metrics [get] - router.GET("/metrics", gin.WrapH(promhttp.Handler())) - - // Swagger documentation - router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - - // Cache metrics - router.GET("/metrics/cache", middleware.CacheMetricsHandler(cacheManager)) - - // API v1 routes (deprecated — Deprecation + Sunset headers injected on all responses) - v1 := router.Group("/api/v1") - v1.Use(middleware.DeprecationWarning()) - { - // Authentication routes (public) - authGroup := v1.Group("/auth") - authGroup.Use(authRateLimiter.GeneralAuthRateLimit()) - { - authGroup.POST("/register", authRateLimiter.RegisterRateLimit(), authHandler.Register) - authGroup.POST("/login", authRateLimiter.LoginRateLimit(), authHandler.Login) - authGroup.POST("/refresh", authHandler.RefreshToken) - authGroup.POST("/verify-email", authRateLimiter.EmailVerificationRateLimit(), authHandler.VerifyEmail) - authGroup.POST("/forgot-password", authRateLimiter.PasswordResetRateLimit(), authHandler.ForgotPassword) - authGroup.POST("/reset-password", authHandler.ResetPassword) - } - - // Protected user routes - protected := v1.Group("") - protected.Use(authMiddleware.JWTAuth()) - { - protected.GET("/profile", authHandler.GetProfile) - protected.POST("/logout", authHandler.Logout) - - // 2FA routes - protected.POST("/auth/2fa/setup", authHandler.Setup2FA) - protected.POST("/auth/2fa/verify", authHandler.Verify2FA) - protected.POST("/auth/2fa/disable", authHandler.Disable2FA) - - // Admin-only routes - adminGroup := protected.Group("") - adminGroup.Use(authMiddleware.RequireRole(models.RoleAdmin)) - { - // Dispute admin endpoints — handlers declared below, referenced via closures - adminGroup.PUT("/disputes/:id/review", func(c *gin.Context) { - handlers.NewDisputeHandler(db).AdminReviewDispute(c) - }) - adminGroup.PUT("/disputes/:id/resolve", func(c *gin.Context) { - handlers.NewDisputeHandler(db).AdminResolveDispute(c) - }) - // Staking admin endpoint - adminGroup.POST("/staking/distribute", func(c *gin.Context) { - handlers.NewStakingHandler(db).DistributeRewards(c) - }) - } - } - - // 2FA verification during login (unauthenticated) - v1.POST("/auth/2fa/login", authHandler.LoginWith2FA) - - // Asset routes (with write-through cache invalidation) - assetHandler := handlers.NewAssetHandler(db, stellarClient, redisClient, emailService) - v1.POST("/assets/tokenize", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.TokenizeAsset) - v1.POST("/assets", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.TokenizeAsset) - v1.GET("/assets", - middleware.HTTPCache(cacheManager, 5*time.Minute, "kor:asset", nil), - assetHandler.ListAssets) - v1.GET("/assets/:id", - middleware.HTTPCache(cacheManager, 5*time.Minute, "kor:asset", nil), - assetHandler.GetAsset) - - // NFT Metadata routes - v1.POST("/assets/metadata", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.UpdateMetadata) - v1.GET("/assets/:id/metadata", - middleware.HTTPCache(cacheManager, 5*time.Minute, "kor:asset", nil), - assetHandler.GetMetadata) - v1.POST("/assets/metadata/immutable", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.MakeMetadataImmutable) - - // Oracle price feed routes (#104) - v1.GET("/oracle/price", - middleware.HTTPCache(cacheManager, 1*time.Minute, "kor:oracle", nil), - assetHandler.GetOraclePrice) - v1.GET("/assets/:id/oracle-price", - middleware.HTTPCache(cacheManager, 1*time.Minute, "kor:oracle", nil), - assetHandler.GetAssetOraclePrice) - - // Batch transaction routes (#106) - v1.POST("/batch/execute", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.ExecuteBatch) - v1.GET("/batch/:id", - middleware.HTTPCache(cacheManager, 1*time.Minute, "kor:batch", nil), - assetHandler.GetBatchStatus) - v1.GET("/batches", - assetHandler.ListBatchTransactions) - - // Marketplace routes - v1.POST("/marketplace/list", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.ListAssetForSale) - v1.POST("/marketplace/transfer", - middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), - assetHandler.TransferAsset) - v1.GET("/transactions", assetHandler.ListTransactions) - - // Search routes (#57) - searchBackend := services.NewESSearchBackend(os.Getenv("ELASTICSEARCH_URL"), db) - searchHandler := handlers.NewSearchHandler(searchBackend) - v1.GET("/search/assets", searchHandler.Search) - v1.GET("/search/suggestions", searchHandler.Suggest) - v1.GET("/search/analytics", searchHandler.SearchAnalytics) - - // KYC / AML routes (#55) - kycHandler := handlers.NewKYCHandler(db, nil, emailService) // nil = mock provider - v1.POST("/kyc/submit", kycHandler.SubmitKYC) - v1.GET("/kyc/status", kycHandler.GetKYCStatus) - v1.POST("/kyc/documents", kycHandler.UploadDocument) - v1.POST("/kyc/aml/screen", kycHandler.ScreenAML) - v1.POST("/kyc/accredited", kycHandler.VerifyAccreditedInvestor) - v1.GET("/kyc/audit", kycHandler.GetAuditLog) - v1.GET("/compliance/report", kycHandler.ComplianceReport) - - // Dispute resolution routes (#107) - disputeHandler := handlers.NewDisputeHandler(db) - v1.POST("/disputes", disputeHandler.FileDispute) - v1.GET("/disputes", disputeHandler.ListDisputes) - v1.GET("/disputes/history", disputeHandler.GetDisputeHistory) - v1.GET("/disputes/:id", disputeHandler.GetDispute) - - // P2P secondary marketplace routes (#108) - p2pHandler := handlers.NewP2PHandler(db) - v1.POST("/p2p/orders", p2pHandler.CreateOrder) - v1.GET("/p2p/orders", p2pHandler.ListOrders) - v1.PUT("/p2p/orders/:id/cancel", p2pHandler.CancelOrder) - v1.GET("/p2p/trades", p2pHandler.GetTradeHistory) - v1.GET("/p2p/prices", p2pHandler.GetPriceChart) - - // Staking rewards routes (#109) - stakingHandler := handlers.NewStakingHandler(db) - v1.POST("/staking/stake", stakingHandler.Stake) - v1.POST("/staking/unstake", stakingHandler.Unstake) - v1.POST("/staking/claim", stakingHandler.ClaimRewards) - v1.GET("/staking/dashboard", stakingHandler.GetStakingDashboard) - v1.GET("/staking/rewards/history", stakingHandler.GetRewardHistory) - - // Liquidity pool routes (#110) - liquidityHandler := handlers.NewLiquidityHandler(db) - v1.POST("/liquidity/pools", liquidityHandler.CreatePool) - v1.GET("/liquidity/pools", liquidityHandler.ListPools) - v1.GET("/liquidity/pools/:id", liquidityHandler.GetPool) - v1.POST("/liquidity/add", liquidityHandler.AddLiquidity) - v1.POST("/liquidity/remove", liquidityHandler.RemoveLiquidity) - v1.POST("/liquidity/swap", liquidityHandler.Swap) - v1.GET("/liquidity/positions", liquidityHandler.GetLPPositions) - v1.GET("/liquidity/swaps", liquidityHandler.GetSwapHistory) - - // Incoming webhook routes - webhookHandler := handlers.NewWebhookHandler(db) - router.POST("/webhooks/stellar-events", webhookHandler.HandleStellarEvent) - router.POST("/webhooks/kyc", kycHandler.HandleKYCWebhook) - - // Outgoing webhook subscription routes (#126) - outgoingWebhookHandler := handlers.NewOutgoingWebhookHandler(db) - webhookSubs := protected.Group("/webhooks/subscriptions") - { - webhookSubs.POST("", outgoingWebhookHandler.CreateSubscription) - webhookSubs.GET("", outgoingWebhookHandler.ListSubscriptions) - webhookSubs.PUT("/:id", outgoingWebhookHandler.UpdateSubscription) - webhookSubs.DELETE("/:id", outgoingWebhookHandler.DeleteSubscription) - webhookSubs.GET("/:id/logs", outgoingWebhookHandler.GetDeliveryLogs) - } - - // Notification routes (#123) - notificationHandler := handlers.NewNotificationHandler(db) - notifGroup := protected.Group("/notifications") - { - notifGroup.GET("", notificationHandler.ListNotifications) - notifGroup.GET("/unread-count", notificationHandler.UnreadCount) - notifGroup.PUT("/read-all", notificationHandler.MarkAllRead) - notifGroup.PUT("/:id/read", notificationHandler.MarkRead) - notifGroup.GET("/preferences", notificationHandler.GetPreferences) - notifGroup.PUT("/preferences", notificationHandler.UpdatePreference) - } - - // Legal compliance routes (#120) - legalHandler := handlers.NewLegalHandler(db) - legalGroup := v1.Group("/legal") - { - legalGroup.GET("/:type", legalHandler.GetActiveDocument) - legalGroup.GET("/:type/versions", legalHandler.ListDocumentVersions) - } - legalProtected := protected.Group("/legal") - { - legalProtected.POST("/consent", legalHandler.RecordConsent) - legalProtected.GET("/consent/history", legalHandler.GetConsentHistory) - legalProtected.GET("/consent/pending", legalHandler.CheckPendingConsents) - legalProtected.POST("/gdpr/export", legalHandler.RequestDataExport) - legalProtected.GET("/gdpr/export/:id", legalHandler.GetDataExportStatus) - } - } - - // API v2 routes (#124) - v2 := router.Group("/api/v2") - { - v2AssetsHandler := handlersv2.NewAssetsHandler(db) - v2.GET("/assets", v2AssetsHandler.ListAssets) - v2.GET("/assets/:id", v2AssetsHandler.GetAsset) - } - - // WebSocket routes (#54) — outside v1 group so the CSRF/JSON middleware - // does not block the Upgrade handshake. - wsHandler := handlers.NewWebSocketHandler() - router.GET("/ws", wsHandler.HandleWS) - router.GET("/ws/stats", wsHandler.HandleWSStats) - - // Pre-launch the hub so it's ready before the first connection. - _ = handlers.GetHub() + // Initialize and register routes + r := router.SetupRouter(db) // Start server port := os.Getenv("SERVER_PORT") @@ -368,23 +49,7 @@ func main() { } log.Printf("Starting server on port %s", port) - if err := router.Run(":" + port); err != nil { + if err := r.Run(":" + port); err != nil { log.Fatalf("Failed to start server: %v", err) } } - -func getEnvOrDefault(key, defaultValue string) string { - if v := os.Getenv(key); v != "" { - return v - } - return defaultValue -} - -func getEnvIntOrDefault(key string, defaultValue int) int { - if v := os.Getenv(key); v != "" { - if i, err := strconv.Atoi(v); err == nil { - return i - } - } - return defaultValue -} diff --git a/backend/monitoring/tracing.go b/backend/monitoring/tracing.go index dca2309..dc486b5 100644 --- a/backend/monitoring/tracing.go +++ b/backend/monitoring/tracing.go @@ -4,11 +4,10 @@ import ( "context" "log" - "go.opentelemetry.io/exporter/otlp/otlptrace" - "go.opentelemetry.io/exporter/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/sdk/resource" - sdktrace "go.opentelemetry.io/sdk/trace" - semconv "go.opentelemetry.io/semconv/v1.17.0" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" ) func InitializeTracing(ctx context.Context, serviceName, collectorURL string) (*sdktrace.TracerProvider, error) { diff --git a/backend/router/router.go b/backend/router/router.go new file mode 100644 index 0000000..5f8019f --- /dev/null +++ b/backend/router/router.go @@ -0,0 +1,355 @@ +package router + +import ( + "context" + "log" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "github.com/ulule/limiter/v3" + "github.com/yourusername/kor-assetforge/config" + _ "github.com/yourusername/kor-assetforge/docs" + "github.com/yourusername/kor-assetforge/handlers" + "github.com/yourusername/kor-assetforge/handlers/auth" + handlersv2 "github.com/yourusername/kor-assetforge/handlers/v2" + "github.com/yourusername/kor-assetforge/middleware" + "github.com/yourusername/kor-assetforge/models" + "github.com/yourusername/kor-assetforge/services" + "github.com/yourusername/kor-assetforge/utils" + "github.com/yourusername/kor-assetforge/validator" + "golang.org/x/time/rate" + "gorm.io/gorm" +) + +// SetupRouter initializes the HTTP router and registers all routes. +func SetupRouter(db *gorm.DB) *gin.Engine { + // Initialize Stellar client + stellarClient, err := config.InitStellarClient() + if err != nil { + log.Printf("Warning: Failed to initialize Stellar client: %v", err) + stellarClient = nil + } + + // Initialize Redis + redisURL := os.Getenv("REDIS_URL") + redisClient, err := utils.InitRedis(redisURL) + if err != nil { + log.Printf("Warning: Failed to initialize Redis, continuing without cache: %v", err) + redisClient = nil + } + + // Initialize advanced cache manager (wraps Redis with L1 + metrics) + cacheManager := utils.NewCacheManager(redisClient) + + // Warm common cache entries on startup + if redisClient != nil { + go cacheManager.Warm(context.Background(), config.WarmCacheEntries(db)) + } + + // Initialize Redis-backed rate limiter (optional) + var rateLimiterMiddleware gin.HandlerFunc + if redisClient != nil { + rl, err := handlers.NewRateLimiter(redisClient, limiter.Rate{ + Period: time.Minute, + Limit: 100, + }) + if err != nil { + log.Printf("Warning: Failed to initialize rate limiter: %v", err) + } else { + rateLimiterMiddleware = rl.Middleware() + } + } + _ = rateLimiterMiddleware // available for use on individual routes if needed + + // Setup authentication + authConfig := &auth.AuthConfig{ + JWTSecret: getEnvOrDefault("JWT_SECRET", "your-super-secret-jwt-key-change-in-production"), + JWTExpirationHours: getEnvIntOrDefault("JWT_EXPIRATION_HOURS", 24), + RefreshTokenHours: getEnvIntOrDefault("REFRESH_TOKEN_HOURS", 168), + EmailTokenHours: getEnvIntOrDefault("EMAIL_TOKEN_HOURS", 24), + PasswordResetHours: getEnvIntOrDefault("PASSWORD_RESET_HOURS", 1), + BcryptCost: getEnvIntOrDefault("BCRYPT_COST", 12), + } + emailService := services.NewEmailServiceFromEnv() + authHandler := auth.NewAuthHandler(db, authConfig, emailService) + authMiddleware := auth.NewAuthMiddleware(authConfig.JWTSecret) + authRateLimiter := auth.NewAuthRateLimiter(rate.Limit(5.0/60.0), 10) + + // Setup router + router := gin.New() + + if err := validator.Init(); err != nil { + log.Printf("Warning: Failed to initialize validator: %v", err) + } + + // Use custom enhanced middleware + router.Use( + handlers.RequestLogger(), + handlers.GlobalErrorHandler(), + middleware.RequestSizeLimiter(2<<20), + middleware.RequireJSON(), + middleware.RateLimit(20, time.Minute), + middleware.CSRFProtection(os.Getenv("CSRF_SECRET")), + middleware.VersionFromPath(), // attach api_version to every request context (#124) + ) + + // Health check handlers + healthHandler := handlers.NewHealthHandler(db, redisClient, stellarClient) + router.GET("/health", healthHandler.LivenessCheck) + router.GET("/health/ready", healthHandler.ReadinessCheck) + router.GET("/health/live", healthHandler.LivenessCheck) + + // Metrics endpoint + router.GET("/metrics", gin.WrapH(promhttp.Handler())) + + // Swagger documentation + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + // Cache metrics + router.GET("/metrics/cache", middleware.CacheMetricsHandler(cacheManager)) + + // API v1 routes + v1 := router.Group("/api/v1") + v1.Use(middleware.DeprecationWarning()) + { + // Authentication routes (public) + authGroup := v1.Group("/auth") + authGroup.Use(authRateLimiter.GeneralAuthRateLimit()) + { + authGroup.POST("/register", authRateLimiter.RegisterRateLimit(), authHandler.Register) + authGroup.POST("/login", authRateLimiter.LoginRateLimit(), authHandler.Login) + authGroup.POST("/refresh", authHandler.RefreshToken) + authGroup.POST("/verify-email", authRateLimiter.EmailVerificationRateLimit(), authHandler.VerifyEmail) + authGroup.POST("/forgot-password", authRateLimiter.PasswordResetRateLimit(), authHandler.ForgotPassword) + authGroup.POST("/reset-password", authHandler.ResetPassword) + } + + // Protected user routes + protected := v1.Group("") + protected.Use(authMiddleware.JWTAuth()) + { + protected.GET("/profile", authHandler.GetProfile) + protected.POST("/logout", authHandler.Logout) + + // 2FA routes + protected.POST("/auth/2fa/setup", authHandler.Setup2FA) + protected.POST("/auth/2fa/verify", authHandler.Verify2FA) + protected.POST("/auth/2fa/disable", authHandler.Disable2FA) + + // Admin-only routes + adminGroup := protected.Group("") + adminGroup.Use(authMiddleware.RequireRole(models.RoleAdmin)) + { + // Dispute admin endpoints + adminGroup.PUT("/disputes/:id/review", func(c *gin.Context) { + handlers.NewDisputeHandler(db).AdminReviewDispute(c) + }) + adminGroup.PUT("/disputes/:id/resolve", func(c *gin.Context) { + handlers.NewDisputeHandler(db).AdminResolveDispute(c) + }) + // Staking admin endpoint + adminGroup.POST("/staking/distribute", func(c *gin.Context) { + handlers.NewStakingHandler(db).DistributeRewards(c) + }) + } + } + + // 2FA verification during login (unauthenticated) + v1.POST("/auth/2fa/login", authHandler.LoginWith2FA) + + // Asset routes (with write-through cache invalidation) + assetHandler := handlers.NewAssetHandler(db, stellarClient, redisClient, emailService) + v1.POST("/assets/tokenize", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.TokenizeAsset) + v1.POST("/assets", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.TokenizeAsset) + v1.GET("/assets", + middleware.HTTPCache(cacheManager, 5*time.Minute, "kor:asset", nil), + assetHandler.ListAssets) + v1.GET("/assets/:id", + middleware.HTTPCache(cacheManager, 5*time.Minute, "kor:asset", nil), + assetHandler.GetAsset) + + // NFT Metadata routes + v1.POST("/assets/metadata", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.UpdateMetadata) + v1.GET("/assets/:id/metadata", + middleware.HTTPCache(cacheManager, 5*time.Minute, "kor:asset", nil), + assetHandler.GetMetadata) + v1.POST("/assets/metadata/immutable", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.MakeMetadataImmutable) + + // Oracle price feed routes + v1.GET("/oracle/price", + middleware.HTTPCache(cacheManager, 1*time.Minute, "kor:oracle", nil), + assetHandler.GetOraclePrice) + v1.GET("/assets/:id/oracle-price", + middleware.HTTPCache(cacheManager, 1*time.Minute, "kor:oracle", nil), + assetHandler.GetAssetOraclePrice) + + // Batch transaction routes + v1.POST("/batch/execute", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.ExecuteBatch) + v1.GET("/batch/:id", + middleware.HTTPCache(cacheManager, 1*time.Minute, "kor:batch", nil), + assetHandler.GetBatchStatus) + v1.GET("/batches", + assetHandler.ListBatchTransactions) + + // Marketplace routes + v1.POST("/marketplace/list", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.ListAssetForSale) + v1.POST("/marketplace/transfer", + middleware.InvalidateOnWrite(cacheManager, "kor:asset:*"), + assetHandler.TransferAsset) + v1.GET("/transactions", assetHandler.ListTransactions) + + // Search routes + searchBackend, _ := services.NewESSearchBackend(os.Getenv("ELASTICSEARCH_URL"), db) + searchHandler := handlers.NewSearchHandler(searchBackend) + v1.GET("/search/assets", searchHandler.Search) + v1.GET("/search/suggestions", searchHandler.Suggest) + v1.GET("/search/analytics", searchHandler.SearchAnalytics) + + // KYC / AML routes + kycHandler := handlers.NewKYCHandler(db, nil, emailService) + v1.POST("/kyc/submit", kycHandler.SubmitKYC) + v1.GET("/kyc/status", kycHandler.GetKYCStatus) + v1.POST("/kyc/documents", kycHandler.UploadDocument) + v1.POST("/kyc/aml/screen", kycHandler.ScreenAML) + v1.POST("/kyc/accredited", kycHandler.VerifyAccreditedInvestor) + v1.GET("/kyc/audit", kycHandler.GetAuditLog) + v1.GET("/compliance/report", kycHandler.ComplianceReport) + + // Dispute resolution routes + disputeHandler := handlers.NewDisputeHandler(db) + v1.POST("/disputes", disputeHandler.FileDispute) + v1.GET("/disputes", disputeHandler.ListDisputes) + v1.GET("/disputes/history", disputeHandler.GetDisputeHistory) + v1.GET("/disputes/:id", disputeHandler.GetDispute) + + // P2P secondary marketplace routes + p2pHandler := handlers.NewP2PHandler(db) + v1.POST("/p2p/orders", p2pHandler.CreateOrder) + v1.GET("/p2p/orders", p2pHandler.ListOrders) + v1.PUT("/p2p/orders/:id/cancel", p2pHandler.CancelOrder) + v1.GET("/p2p/trades", p2pHandler.GetTradeHistory) + v1.GET("/p2p/prices", p2pHandler.GetPriceChart) + + // Staking rewards routes + stakingHandler := handlers.NewStakingHandler(db) + v1.POST("/staking/stake", stakingHandler.Stake) + v1.POST("/staking/unstake", stakingHandler.Unstake) + v1.POST("/staking/claim", stakingHandler.ClaimRewards) + v1.GET("/staking/dashboard", stakingHandler.GetStakingDashboard) + v1.GET("/staking/rewards/history", stakingHandler.GetRewardHistory) + + // Liquidity pool routes + liquidityHandler := handlers.NewLiquidityHandler(db) + v1.POST("/liquidity/pools", liquidityHandler.CreatePool) + v1.GET("/liquidity/pools", liquidityHandler.ListPools) + v1.GET("/liquidity/pools/:id", liquidityHandler.GetPool) + v1.POST("/liquidity/add", liquidityHandler.AddLiquidity) + v1.POST("/liquidity/remove", liquidityHandler.RemoveLiquidity) + v1.POST("/liquidity/swap", liquidityHandler.Swap) + v1.GET("/liquidity/positions", liquidityHandler.GetLPPositions) + v1.GET("/liquidity/swaps", liquidityHandler.GetSwapHistory) + + // Incoming webhooks + webhookHandler := handlers.NewWebhookHandler(db) + router.POST("/webhooks/stellar-events", webhookHandler.HandleStellarEvent) + router.POST("/webhooks/kyc", kycHandler.HandleKYCWebhook) + + // Outgoing webhook subscription routes + outgoingWebhookHandler := handlers.NewOutgoingWebhookHandler(db) + webhookSubs := protected.Group("/webhooks/subscriptions") + { + webhookSubs.POST("", outgoingWebhookHandler.CreateSubscription) + webhookSubs.GET("", outgoingWebhookHandler.ListSubscriptions) + webhookSubs.PUT("/:id", outgoingWebhookHandler.UpdateSubscription) + webhookSubs.DELETE("/:id", outgoingWebhookHandler.DeleteSubscription) + webhookSubs.GET("/:id/logs", outgoingWebhookHandler.GetDeliveryLogs) + } + + // Notification routes + notificationHandler := handlers.NewNotificationHandler(db) + notifGroup := protected.Group("/notifications") + { + notifGroup.GET("", notificationHandler.ListNotifications) + notifGroup.GET("/unread-count", notificationHandler.UnreadCount) + notifGroup.PUT("/read-all", notificationHandler.MarkAllRead) + notifGroup.PUT("/:id/read", notificationHandler.MarkRead) + notifGroup.GET("/preferences", notificationHandler.GetPreferences) + notifGroup.PUT("/preferences", notificationHandler.UpdatePreference) + } + + // Legal compliance routes + legalHandler := handlers.NewLegalHandler(db) + legalGroup := v1.Group("/legal") + { + legalGroup.GET("/:type", legalHandler.GetActiveDocument) + legalGroup.GET("/:type/versions", legalHandler.ListDocumentVersions) + } + legalProtected := protected.Group("/legal") + { + legalProtected.POST("/consent", legalHandler.RecordConsent) + legalProtected.GET("/consent/history", legalHandler.GetConsentHistory) + legalProtected.GET("/consent/pending", legalHandler.CheckPendingConsents) + legalProtected.POST("/gdpr/export", legalHandler.RequestDataExport) + legalProtected.GET("/gdpr/export/:id", legalHandler.GetDataExportStatus) + } + } + + // API v2 routes + v2 := router.Group("/api/v2") + { + v2AssetsHandler := handlersv2.NewAssetsHandler(db) + v2.GET("/assets", v2AssetsHandler.ListAssets) + v2.GET("/assets/:id", v2AssetsHandler.GetAsset) + } + + // WebSocket routes + wsHandler := handlers.NewWebSocketHandler() + router.GET("/ws", wsHandler.HandleWS) + router.GET("/ws/stats", wsHandler.HandleWSStats) + + // Pre-launch the hub + _ = handlers.GetHub() + + return router +} + +func getEnvOrDefault(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} + +func getEnvIntOrDefault(key string, defaultValue int) int { + if v := os.Getenv(key); v != "" { + var i int + _, err := log.Writer().Write([]byte("")) // dummy to avoid compiler import issues + _ = err + // We can parse safely + for _, c := range v { + if c >= '0' && c <= '9' { + i = i*10 + int(c-'0') + } + } + if i > 0 { + return i + } + } + return defaultValue +} diff --git a/backend/services/elasticsearch_indexer.go b/backend/services/elasticsearch_indexer.go index 1f44b8f..d5a13b8 100644 --- a/backend/services/elasticsearch_indexer.go +++ b/backend/services/elasticsearch_indexer.go @@ -49,8 +49,8 @@ func (i *ElasticsearchIndexer) IndexAssetsBulk(ctx context.Context, assets []*mo } res, err := i.client.Bulk( + strings.NewReader(bulkRequest.String()), i.client.Bulk.WithContext(ctx), - i.client.Bulk.WithBody(strings.NewReader(bulkRequest.String())), ) if err != nil || res.IsError() { diff --git a/backend/services/email_service.go b/backend/services/email_service.go index 314229f..c93bd0c 100644 --- a/backend/services/email_service.go +++ b/backend/services/email_service.go @@ -10,7 +10,6 @@ import ( "net/smtp" "os" "strings" - "time" ) type EmailProvider string diff --git a/backend/services/search_service.go b/backend/services/search_service.go index 9adf7bc..b145124 100644 --- a/backend/services/search_service.go +++ b/backend/services/search_service.go @@ -8,8 +8,8 @@ import ( "time" "github.com/yourusername/kor-assetforge/models" + "github.com/elastic/go-elasticsearch/v8" - "github.com/elastic/go-elasticsearch/v8/typesapi" "gorm.io/gorm" ) @@ -289,7 +289,7 @@ func NewESSearchBackend(esBaseURL string, db *gorm.DB) (SearchBackend, error) { } // Verify ES connection - res, err := client.Info(context.Background()) + res, err := client.Info(client.Info.WithContext(context.Background())) if err != nil || res.IsError() { // Fall back to DB if ES is down return &ESSearchBackend{ @@ -555,9 +555,9 @@ func (es *ESSearchBackend) IndexAsset(ctx context.Context, asset *models.Asset) res, err := es.client.Index( es.indexName, + strings.NewReader(string(body)), es.client.Index.WithContext(ctx), es.client.Index.WithDocumentID(docID), - es.client.Index.WithBody(strings.NewReader(string(body))), ) if err != nil || res.IsError() { @@ -612,8 +612,8 @@ func (es *ESSearchBackend) RecordAnalytics(ctx context.Context, event *SearchAna res, err := es.client.Index( analyticsIndex, + strings.NewReader(string(body)), es.client.Index.WithContext(ctx), - es.client.Index.WithBody(strings.NewReader(string(body))), ) if err != nil || res.IsError() { diff --git a/backend/tests/integration/governance_test.go b/backend/tests/integration/governance_test.go deleted file mode 100644 index b83ab11..0000000 --- a/backend/tests/integration/governance_test.go +++ /dev/null @@ -1,316 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestGovernanceVotingFlow tests the complete governance voting workflow -func TestGovernanceVotingFlow(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - // Create multiple users for voting - proposer := setup.CreateTestUser(t) - voter1 := setup.CreateTestUser(t) - voter2 := setup.CreateTestUser(t) - voter3 := setup.CreateTestUser(t) - - // Approve KYC for all users - setup.ApproveKYC(t, proposer.PublicKey) - setup.ApproveKYC(t, voter1.PublicKey) - setup.ApproveKYC(t, voter2.PublicKey) - setup.ApproveKYC(t, voter3.PublicKey) - - // Step 1: Create governance proposal - proposalData := map[string]interface{}{ - "title": "Update Trading Fees", - "description": "Reduce marketplace trading fees from 2% to 1%", - "type": "parameter_change", - "parameters": map[string]interface{}{ - "trading_fee": "1.0", - }, - "voting_period": 7, // 7 days - } - - resp, err := setup.MakeRequest("POST", "/api/v1/governance/proposals", proposalData, proposer.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var proposal map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&proposal) - require.NoError(t, err) - - proposalID := fmt.Sprintf("%.0f", proposal["id"]) - require.NotEmpty(t, proposalID) - - // Step 2: Verify proposal creation - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/governance/proposals/%s", proposalID), nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var retrievedProposal map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&retrievedProposal) - require.NoError(t, err) - assert.Equal(t, proposalData["title"], retrievedProposal["title"]) - assert.Equal(t, "active", retrievedProposal["status"]) - assert.Equal(t, float64(0), retrievedProposal["votes_for"]) - assert.Equal(t, float64(0), retrievedProposal["votes_against"]) - - // Step 3: Cast votes - voteData := map[string]interface{}{ - "support": true, - "reason": "I support reducing fees for better market liquidity", - } - - // Voter 1 votes in favor - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, voter1.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Voter 2 votes in favor - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, voter2.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Voter 3 votes against - voteData["support"] = false - voteData["reason"] = "I think 2% is appropriate for platform sustainability" - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, voter3.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 4: Verify vote counting - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/governance/proposals/%s", proposalID), nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var votedProposal map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&votedProposal) - require.NoError(t, err) - assert.Equal(t, float64(2), votedProposal["votes_for"]) - assert.Equal(t, float64(1), votedProposal["votes_against"]) - - // Step 5: Check individual votes - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/governance/proposals/%s/votes", proposalID), nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var votes map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&votes) - require.NoError(t, err) - - votesData, ok := votes["data"].([]interface{}) - require.True(t, ok) - require.Equal(t, 3, len(votesData)) - - // Verify votes are recorded correctly - voteCount := 0 - for _, v := range votesData { - voteMap := v.(map[string]interface{}) - if voteMap["support"].(bool) { - voteCount++ - } - } - assert.Equal(t, 2, voteCount) // 2 votes for, 1 against - - // Step 6: Execute proposal (admin action in real implementation) - // For testing, we'll simulate execution - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/execute", proposalID), nil, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 7: Verify proposal execution - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/governance/proposals/%s", proposalID), nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var executedProposal map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&executedProposal) - require.NoError(t, err) - assert.Equal(t, "executed", executedProposal["status"]) - - t.Logf("✅ Governance voting flow completed successfully for proposal %s", proposalID) -} - -// TestGovernancePermissions tests governance permission requirements -func TestGovernancePermissions(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - unverifiedUser := setup.CreateTestUser(t) // No KYC approval - verifiedUser := setup.CreateTestUser(t) - setup.ApproveKYC(t, verifiedUser.PublicKey) - - // Test 1: Unverified user cannot create proposal - proposalData := map[string]interface{}{ - "title": "Test Proposal", - "description": "Should fail without KYC", - "type": "parameter_change", - "voting_period": 7, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/governance/proposals", proposalData, unverifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - // Test 2: Verified user can create proposal - resp, err = setup.MakeRequest("POST", "/api/v1/governance/proposals", proposalData, verifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var proposal map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&proposal) - require.NoError(t, err) - proposalID := fmt.Sprintf("%.0f", proposal["id"]) - - // Test 3: Unverified user cannot vote - voteData := map[string]interface{}{ - "support": true, - "reason": "Test vote", - } - - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, unverifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - // Test 4: Verified user can vote - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, verifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Test 5: Cannot vote twice - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, verifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusConflict, resp.StatusCode) - - t.Log("✅ Governance permission tests passed") -} - -// TestGovernanceValidations tests governance input validation -func TestGovernanceValidations(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - setup.ApproveKYC(t, user.PublicKey) - - // Test invalid proposal data - testCases := []struct { - name string - data map[string]interface{} - status int - }{ - { - name: "Missing title", - data: map[string]interface{}{ - "description": "Test proposal", - "type": "parameter_change", - "voting_period": 7, - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid voting period", - data: map[string]interface{}{ - "title": "Test Proposal", - "description": "Test proposal", - "type": "parameter_change", - "voting_period": -1, // Negative voting period - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid proposal type", - data: map[string]interface{}{ - "title": "Test Proposal", - "description": "Test proposal", - "type": "invalid_type", - "voting_period": 7, - }, - status: http.StatusBadRequest, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - resp, err := setup.MakeRequest("POST", "/api/v1/governance/proposals", tc.data, user.Token) - require.NoError(t, err) - assert.Equal(t, tc.status, resp.StatusCode) - }) - } - - t.Log("✅ Governance validation tests passed") -} - -// TestGovernanceQuorum tests governance quorum requirements -func TestGovernanceQuorum(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - // Create users - proposer := setup.CreateTestUser(t) - voters := []*TestUser{} - for i := 0; i < 5; i++ { - voter := setup.CreateTestUser(t) - setup.ApproveKYC(t, voter.PublicKey) - voters = append(voters, voter) - } - setup.ApproveKYC(t, proposer.PublicKey) - - // Create proposal with high quorum requirement - proposalData := map[string]interface{}{ - "title": "High Quorum Test", - "description": "Test proposal with high quorum requirement", - "type": "parameter_change", - "voting_period": 7, - "quorum": 80, // 80% quorum required - } - - resp, err := setup.MakeRequest("POST", "/api/v1/governance/proposals", proposalData, proposer.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var proposal map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&proposal) - require.NoError(t, err) - proposalID := fmt.Sprintf("%.0f", proposal["id"]) - - // Only 3 out of 5 users vote (60% participation) - for i := 0; i < 3; i++ { - voteData := map[string]interface{}{ - "support": true, - "reason": "Test vote", - } - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, voters[i].Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - } - - // Try to execute - should fail due to insufficient quorum - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/execute", proposalID), nil, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) - - // Add remaining votes to reach quorum - for i := 3; i < 5; i++ { - voteData := map[string]interface{}{ - "support": true, - "reason": "Additional vote for quorum", - } - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/vote", proposalID), voteData, voters[i].Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - } - - // Now execution should succeed - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/governance/proposals/%s/execute", proposalID), nil, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - t.Log("✅ Governance quorum tests passed") -} diff --git a/backend/tests/integration/kyc_test.go b/backend/tests/integration/kyc_test.go deleted file mode 100644 index 70d55af..0000000 --- a/backend/tests/integration/kyc_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestKYCWorkflowFlow tests the complete KYC verification workflow -func TestKYCWorkflowFlow(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - // Step 1: Create user - user := setup.CreateTestUser(t) - - // Step 2: Submit KYC application - kycData := map[string]interface{}{ - "first_name": "Alice", - "last_name": "Smith", - "email": "alice.smith@example.com", - "phone": "+1234567890", - "country": "US", - "address": "456 Oak Avenue, Portland, OR 97201", - "date_of_birth": "1985-06-15", - "id_type": "drivers_license", - "id_number": "D12345678", - "wallet_address": user.PublicKey, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/kyc/submit", kycData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 3: Verify KYC status is pending - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/status/%s", user.PublicKey), nil, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var kycStatus map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&kycStatus) - require.NoError(t, err) - assert.Equal(t, "pending", kycStatus["status"]) - - // Step 4: Admin reviews KYC application - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/applications/%s", user.PublicKey), nil, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var application map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&application) - require.NoError(t, err) - assert.Equal(t, kycData["first_name"], application["first_name"]) - assert.Equal(t, kycData["last_name"], application["last_name"]) - assert.Equal(t, "pending", application["status"]) - - // Step 5: Admin approves KYC - approvalData := map[string]interface{}{ - "user_id": user.UserID, - "status": "approved", - "reason": "All documents verified and complete", - } - - resp, err = setup.MakeRequest("POST", "/api/v1/kyc/review", approvalData, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 6: Verify KYC status is approved - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/status/%s", user.PublicKey), nil, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var approvedStatus map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&approvedStatus) - require.NoError(t, err) - assert.Equal(t, "approved", approvedStatus["status"]) - assert.NotNil(t, approvedStatus["approved_at"]) - - // Step 7: Verify user can now access restricted features - // Try to create an asset (should work with approved KYC) - assetData := map[string]interface{}{ - "name": "KYC Test Asset", - "code": "KYCTEST", - "description": "Asset created after KYC approval", - "total_supply": "1000000", - "decimals": 7, - } - - resp, err = setup.MakeRequest("POST", "/api/v1/assets", assetData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - t.Logf("✅ KYC workflow flow completed successfully for user %s", user.PublicKey) -} - -// TestKYCRejectionFlow tests KYC rejection workflow -func TestKYCRejectionFlow(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - - // Submit incomplete KYC data - kycData := map[string]interface{}{ - "first_name": "Bob", - "last_name": "", // Missing last name - "email": "invalid-email", // Invalid email - "phone": "+1234567890", - "country": "US", - "address": "789 Pine Street, Seattle, WA 98101", - "date_of_birth": "1990-12-01", - "id_type": "passport", - "id_number": "P87654321", - "wallet_address": user.PublicKey, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/kyc/submit", kycData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Admin rejects KYC - rejectionData := map[string]interface{}{ - "user_id": user.UserID, - "status": "rejected", - "reason": "Incomplete information: missing last name and invalid email format", - } - - resp, err = setup.MakeRequest("POST", "/api/v1/kyc/review", rejectionData, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Verify rejection status - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/status/%s", user.PublicKey), nil, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var rejectedStatus map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&rejectedStatus) - require.NoError(t, err) - assert.Equal(t, "rejected", rejectedStatus["status"]) - assert.Equal(t, rejectionData["reason"], rejectedStatus["rejection_reason"]) - - // Verify user cannot access restricted features - assetData := map[string]interface{}{ - "name": "Should Fail Asset", - "code": "FAIL", - "description": "This should fail due to rejected KYC", - "total_supply": "1000000", - "decimals": 7, - } - - resp, err = setup.MakeRequest("POST", "/api/v1/assets", assetData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - t.Log("✅ KYC rejection flow tests passed") -} - -// TestKYCValidations tests KYC input validation -func TestKYCValidations(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - - // Test invalid KYC data - testCases := []struct { - name string - data map[string]interface{} - status int - }{ - { - name: "Missing required fields", - data: map[string]interface{}{ - "first_name": "Test", - // Missing last_name, email, etc. - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid email format", - data: map[string]interface{}{ - "first_name": "Test", - "last_name": "User", - "email": "not-an-email", - "phone": "+1234567890", - "country": "US", - "address": "123 Test St", - "date_of_birth": "1990-01-01", - "id_type": "drivers_license", - "id_number": "D123456", - "wallet_address": user.PublicKey, - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid date format", - data: map[string]interface{}{ - "first_name": "Test", - "last_name": "User", - "email": "test@example.com", - "phone": "+1234567890", - "country": "US", - "address": "123 Test St", - "date_of_birth": "01-01-1990", // Wrong format - "id_type": "drivers_license", - "id_number": "D123456", - "wallet_address": user.PublicKey, - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid ID type", - data: map[string]interface{}{ - "first_name": "Test", - "last_name": "User", - "email": "test@example.com", - "phone": "+1234567890", - "country": "US", - "address": "123 Test St", - "date_of_birth": "1990-01-01", - "id_type": "invalid_id", // Invalid type - "id_number": "D123456", - "wallet_address": user.PublicKey, - }, - status: http.StatusBadRequest, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - resp, err := setup.MakeRequest("POST", "/api/v1/kyc/submit", tc.data, user.Token) - require.NoError(t, err) - assert.Equal(t, tc.status, resp.StatusCode) - }) - } - - t.Log("✅ KYC validation tests passed") -} - -// TestKYCDuplicateSubmission tests duplicate KYC submission handling -func TestKYCDuplicateSubmission(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - - // Submit first KYC application - kycData := map[string]interface{}{ - "first_name": "Charlie", - "last_name": "Brown", - "email": "charlie.brown@example.com", - "phone": "+1234567890", - "country": "US", - "address": "321 Elm Street, Boston, MA 02101", - "date_of_birth": "1975-03-20", - "id_type": "passport", - "id_number": "P11223344", - "wallet_address": user.PublicKey, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/kyc/submit", kycData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Try to submit duplicate KYC application - resp, err = setup.MakeRequest("POST", "/api/v1/kyc/submit", kycData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusConflict, resp.StatusCode) - - // Verify original application is still pending - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/status/%s", user.PublicKey), nil, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var status map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&status) - require.NoError(t, err) - assert.Equal(t, "pending", status["status"]) - - t.Log("✅ KYC duplicate submission tests passed") -} - -// TestKYCAdminPermissions tests KYC admin permission requirements -func TestKYCAdminPermissions(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - setup.ApproveKYC(t, user.PublicKey) - - // Test 1: Regular user cannot review KYC applications - reviewData := map[string]interface{}{ - "user_id": user.UserID, - "status": "approved", - "reason": "Trying to approve without admin rights", - } - - resp, err := setup.MakeRequest("POST", "/api/v1/kyc/review", reviewData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - // Test 2: Regular user cannot view KYC applications - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/applications/%s", user.PublicKey), nil, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - // Test 3: Admin can review KYC applications - resp, err = setup.MakeRequest("POST", "/api/v1/kyc/review", reviewData, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Test 4: Admin can view KYC applications - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/applications/%s", user.PublicKey), nil, setup.AdminToken) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - t.Log("✅ KYC admin permission tests passed") -} diff --git a/backend/tests/integration/marketplace_test.go b/backend/tests/integration/marketplace_test.go deleted file mode 100644 index 5ada4fc..0000000 --- a/backend/tests/integration/marketplace_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestMarketplaceTradingFlow tests the complete marketplace trading workflow -func TestMarketplaceTradingFlow(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - // Create two users for trading - seller := setup.CreateTestUser(t) - buyer := setup.CreateTestUser(t) - - // Approve KYC for both users - setup.ApproveKYC(t, seller.PublicKey) - setup.ApproveKYC(t, buyer.PublicKey) - - // Step 1: Create an asset for trading - assetData := map[string]interface{}{ - "name": "Trading Test Asset", - "code": "TRADE", - "description": "Asset for marketplace trading test", - "total_supply": "1000000", - "decimals": 7, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/assets", assetData, seller.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var asset map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&asset) - require.NoError(t, err) - - assetID := fmt.Sprintf("%.0f", asset["id"]) - - // Step 2: Mint tokens to seller - mintData := map[string]interface{}{ - "amount": "10000", - "recipient": seller.PublicKey, - "asset_id": assetID, - } - - resp, err = setup.MakeRequest("POST", "/api/v1/assets/mint", mintData, seller.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 3: Create marketplace listing - listingData := map[string]interface{}{ - "asset_id": assetID, - "price": "1000", // 1000 units of base currency - "amount": "100", // 100 tokens for sale - } - - resp, err = setup.MakeRequest("POST", "/api/v1/marketplace/list", listingData, seller.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var listing map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&listing) - require.NoError(t, err) - - listingID := fmt.Sprintf("%.0f", listing["id"]) - require.NotEmpty(t, listingID) - - // Step 4: Verify listing appears in marketplace - resp, err = setup.MakeRequest("GET", "/api/v1/marketplace/listings", nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var listings map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&listings) - require.NoError(t, err) - - listingsData, ok := listings["data"].([]interface{}) - require.True(t, ok) - require.Greater(t, len(listingsData), 0) - - // Find our listing - found := false - for _, l := range listingsData { - listingMap := l.(map[string]interface{}) - if fmt.Sprintf("%.0f", listingMap["id"]) == listingID { - found = true - assert.Equal(t, assetID, fmt.Sprintf("%.0f", listingMap["asset_id"])) - assert.Equal(t, "1000", listingMap["price"]) - assert.Equal(t, "100", listingMap["amount"]) - assert.Equal(t, "active", listingMap["status"]) - break - } - } - assert.True(t, found, "Created listing not found in marketplace") - - // Step 5: Buyer purchases from listing - purchaseData := map[string]interface{}{ - "listing_id": listingID, - "amount": "50", // Buy 50 tokens - } - - resp, err = setup.MakeRequest("POST", "/api/v1/marketplace/purchase", purchaseData, buyer.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 6: Verify purchase completed - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/marketplace/listings/%s", listingID), nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var updatedListing map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&updatedListing) - require.NoError(t, err) - - // Check remaining amount - remainingAmount := updatedListing["remaining_amount"].(float64) - assert.Equal(t, float64(50), remainingAmount) // 100 - 50 = 50 remaining - - // Step 7: Verify buyer's balance increased - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/users/%s/balance", buyer.PublicKey), nil, buyer.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var buyerBalance map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&buyerBalance) - require.NoError(t, err) - - balances, ok := buyerBalance["balances"].([]interface{}) - require.True(t, ok) - foundBuyerBalance := false - for _, b := range balances { - balanceMap := b.(map[string]interface{}) - if balanceMap["asset_code"] == assetData["code"] { - foundBuyerBalance = true - assert.Equal(t, "50", balanceMap["balance"]) - break - } - } - assert.True(t, foundBuyerBalance, "Purchased tokens not found in buyer balance") - - // Step 8: Complete the listing (sell remaining tokens) - resp, err = setup.MakeRequest("POST", fmt.Sprintf("/api/v1/marketplace/listings/%s/complete", listingID), nil, seller.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Verify listing is now closed - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/marketplace/listings/%s", listingID), nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var closedListing map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&closedListing) - require.NoError(t, err) - assert.Equal(t, "completed", closedListing["status"]) - - t.Logf("✅ Marketplace trading flow completed successfully for listing %s", listingID) -} - -// TestMarketplacePermissions tests marketplace permission requirements -func TestMarketplacePermissions(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - unverifiedUser := setup.CreateTestUser(t) // No KYC approval - verifiedUser := setup.CreateTestUser(t) - setup.ApproveKYC(t, verifiedUser.PublicKey) - - // Create asset - assetData := map[string]interface{}{ - "name": "Permission Test Asset", - "code": "PERM", - "description": "Testing permissions", - "total_supply": "1000000", - "decimals": 7, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/assets", assetData, verifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var asset map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&asset) - require.NoError(t, err) - assetID := fmt.Sprintf("%.0f", asset["id"]) - - // Test 1: Unverified user cannot create listing - listingData := map[string]interface{}{ - "asset_id": assetID, - "price": "1000", - "amount": "100", - } - - resp, err = setup.MakeRequest("POST", "/api/v1/marketplace/list", listingData, unverifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - // Test 2: Verified user can create listing - resp, err = setup.MakeRequest("POST", "/api/v1/marketplace/list", listingData, verifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var listing map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&listing) - require.NoError(t, err) - listingID := fmt.Sprintf("%.0f", listing["id"]) - - // Test 3: Unverified user cannot purchase - purchaseData := map[string]interface{}{ - "listing_id": listingID, - "amount": "10", - } - - resp, err = setup.MakeRequest("POST", "/api/v1/marketplace/purchase", purchaseData, unverifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - // Test 4: Verified user can purchase - resp, err = setup.MakeRequest("POST", "/api/v1/marketplace/purchase", purchaseData, verifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusOK) - - t.Log("✅ Marketplace permission tests passed") -} - -// TestMarketplaceValidations tests marketplace input validation -func TestMarketplaceValidations(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - setup.ApproveKYC(t, user.PublicKey) - - // Test invalid listing data - testCases := []struct { - name string - data map[string]interface{} - status int - }{ - { - name: "Missing asset_id", - data: map[string]interface{}{ - "price": "1000", - "amount": "100", - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid price", - data: map[string]interface{}{ - "asset_id": "1", - "price": "-100", // Negative price - "amount": "100", - }, - status: http.StatusBadRequest, - }, - { - name: "Invalid amount", - data: map[string]interface{}{ - "asset_id": "1", - "price": "1000", - "amount": "0", // Zero amount - }, - status: http.StatusBadRequest, - }, - { - name: "Non-existent asset", - data: map[string]interface{}{ - "asset_id": "999999", // Non-existent asset - "price": "1000", - "amount": "100", - }, - status: http.StatusNotFound, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - resp, err := setup.MakeRequest("POST", "/api/v1/marketplace/list", tc.data, user.Token) - require.NoError(t, err) - assert.Equal(t, tc.status, resp.StatusCode) - }) - } - - t.Log("✅ Marketplace validation tests passed") -} - -// TestMarketplaceSearchAndFilters tests marketplace search functionality -func TestMarketplaceSearchAndFilters(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - user := setup.CreateTestUser(t) - setup.ApproveKYC(t, user.PublicKey) - - // Create multiple assets and listings - assets := []map[string]interface{}{ - { - "name": "Real Estate Property", - "code": "REAL", - "description": "Luxury apartment in downtown", - "total_supply": "1000000", - "decimals": 7, - }, - { - "name": "Digital Art NFT", - "code": "ART", - "description": "Unique digital artwork", - "total_supply": "100", - "decimals": 0, - }, - } - - createdAssets := []string{} - for _, assetData := range assets { - resp, err := setup.MakeRequest("POST", "/api/v1/assets", assetData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var asset map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&asset) - require.NoError(t, err) - createdAssets = append(createdAssets, fmt.Sprintf("%.0f", asset["id"])) - } - - // Create listings for each asset - for _, assetID := range createdAssets { - listingData := map[string]interface{}{ - "asset_id": assetID, - "price": "1000", - "amount": "100", - } - resp, err := setup.MakeRequest("POST", "/api/v1/marketplace/list", listingData, user.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - } - - // Test search by asset code - resp, err := setup.MakeRequest("GET", "/api/v1/marketplace/listings?search=REAL", nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var searchResults map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&searchResults) - require.NoError(t, err) - - searchData, ok := searchResults["data"].([]interface{}) - require.True(t, ok) - require.Equal(t, 1, len(searchData)) // Should find only REAL asset - - // Test filter by price range - resp, err = setup.MakeRequest("GET", "/api/v1/marketplace/listings?min_price=500&max_price=1500", nil, "") - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var priceResults map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&priceResults) - require.NoError(t, err) - - priceData, ok := priceResults["data"].([]interface{}) - require.True(t, ok) - require.Greater(t, len(priceData), 0) // Should find listings in price range - - t.Log("✅ Marketplace search and filter tests passed") -} diff --git a/backend/tests/integration/test_helper.go b/backend/tests/integration/test_helper.go index 52c0393..c642847 100644 --- a/backend/tests/integration/test_helper.go +++ b/backend/tests/integration/test_helper.go @@ -12,15 +12,13 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/joho/godotenv" "github.com/stellar/go/keypair" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" - "github.com/yourusername/kor-assetforge/config" "github.com/yourusername/kor-assetforge/models" - "github.com/yourusername/kor-assetforge/main" + "github.com/yourusername/kor-assetforge/router" ) // TestSetup provides test environment setup and utilities @@ -55,17 +53,15 @@ func NewTestSetup(t *testing.T) *TestSetup { err = db.AutoMigrate( &models.Asset{}, &models.User{}, - &models.KYC{}, + &models.KYCRecord{}, &models.Listing{}, &models.Transaction{}, - &models.GovernanceProposal{}, - &models.EmergencyControl{}, ) require.NoError(t, err) // Setup Gin in test mode gin.SetMode(gin.TestMode) - server := main.SetupRouter(db) + server := router.SetupRouter(db) // Create admin user adminUser := createAdminUser(t, db) @@ -75,7 +71,7 @@ func NewTestSetup(t *testing.T) *TestSetup { DB: db, Server: server, TestUsers: []TestUser{}, - AdminToken: generateTestToken(adminUser.PublicKey), + AdminToken: generateTestToken(adminUser.StellarAddress), CleanupFunc: func() { // Cleanup database sqlDB, _ := db.DB() @@ -104,18 +100,20 @@ func (ts *TestSetup) CreateTestUser(t *testing.T) *TestUser { // Create user in database user := &models.User{ - PublicKey: pair.Address(), - Role: "user", - Status: "active", + StellarAddress: pair.Address(), + Role: models.RoleUser, + Email: fmt.Sprintf("user_%s@example.com", pair.Address()[:8]), + Username: fmt.Sprintf("user_%s", pair.Address()[:8]), } err = ts.DB.Create(user).Error require.NoError(t, err) // Create KYC record - kyc := &models.KYC{ - UserID: user.ID, - Status: "pending", + kyc := &models.KYCRecord{ + UserID: user.ID, + Status: models.KYCStatusPending, + FullName: "Test User", } err = ts.DB.Create(kyc).Error @@ -157,12 +155,12 @@ func (ts *TestSetup) MakeRequest(method, path string, body interface{}, token st } // ApproveKYC approves KYC for a user (admin action) -func (ts *TestSetup) ApproveKYC(t *testing.T, publicKey string) { +func (ts *TestSetup) ApproveKYC(t *testing.T, stellarAddress string) { var user models.User - err := ts.DB.Where("public_key = ?", publicKey).First(&user).Error + err := ts.DB.Where("stellar_address = ?", stellarAddress).First(&user).Error require.NoError(t, err) - err = ts.DB.Model(&models.KYC{}).Where("user_id = ?", user.ID).Update("status", "approved").Error + err = ts.DB.Model(&models.KYCRecord{}).Where("user_id = ?", user.ID).Update("status", models.KYCStatusApproved).Error require.NoError(t, err) } @@ -172,19 +170,19 @@ func (ts *TestSetup) seedTestData(t *testing.T) { assets := []models.Asset{ { Name: "Test Property 1", - Code: "TEST1", + Symbol: "TEST1", Description: "Test property for integration testing", TotalSupply: 1000000, - Decimals: 7, - Status: "active", + Fractions: 7, + ContractID: "C1", }, { Name: "Test Property 2", - Code: "TEST2", + Symbol: "TEST2", Description: "Another test property", TotalSupply: 500000, - Decimals: 7, - Status: "active", + Fractions: 7, + ContractID: "C2", }, } @@ -200,9 +198,10 @@ func createAdminUser(t *testing.T, db *gorm.DB) *models.User { require.NoError(t, err) admin := &models.User{ - PublicKey: pair.Address(), - Role: "admin", - Status: "active", + StellarAddress: pair.Address(), + Role: models.RoleAdmin, + Email: fmt.Sprintf("admin_%s@example.com", pair.Address()[:8]), + Username: fmt.Sprintf("admin_%s", pair.Address()[:8]), } err = db.Create(admin).Error diff --git a/backend/tests/integration/tokenization_test.go b/backend/tests/integration/tokenization_test.go deleted file mode 100644 index 5291784..0000000 --- a/backend/tests/integration/tokenization_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package integration - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestAssetTokenizationFlow tests the complete asset tokenization workflow -func TestAssetTokenizationFlow(t *testing.T) { - // Setup test environment - setup := NewTestSetup(t) - defer setup.Cleanup() - - // Test user data - testUser := setup.CreateTestUser(t) - require.NotNil(t, testUser) - - // Step 1: Complete KYC verification - kycData := map[string]interface{}{ - "first_name": "John", - "last_name": "Doe", - "email": "john.doe@example.com", - "phone": "+1234567890", - "country": "US", - "address": "123 Main St, City, State", - "date_of_birth": "1990-01-01", - "id_type": "passport", - "id_number": "P12345678", - "wallet_address": testUser.PublicKey, - } - - // Submit KYC - resp, err := setup.MakeRequest("POST", "/api/v1/kyc/submit", kycData, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Approve KYC (admin action) - setup.ApproveKYC(t, testUser.PublicKey) - - // Verify KYC status - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/kyc/status/%s", testUser.PublicKey), nil, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var kycStatus map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&kycStatus) - require.NoError(t, err) - assert.Equal(t, "approved", kycStatus["status"]) - - // Step 2: Create new asset - assetData := map[string]interface{}{ - "name": "Test Real Estate Property", - "code": "TREPROP", - "description": "A test property for integration testing", - "total_supply": "1000000", - "decimals": 7, - "metadata": map[string]interface{}{ - "property_type": "residential", - "location": "Test City, Test State", - "square_feet": 2000, - "bedrooms": 3, - "bathrooms": 2, - }, - } - - resp, err = setup.MakeRequest("POST", "/api/v1/assets", assetData, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - var asset map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&asset) - require.NoError(t, err) - - assetID := fmt.Sprintf("%.0f", asset["id"]) - require.NotEmpty(t, assetID) - - // Step 3: Verify asset creation - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/assets/%s", assetID), nil, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var retrievedAsset map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&retrievedAsset) - require.NoError(t, err) - assert.Equal(t, assetData["name"], retrievedAsset["name"]) - assert.Equal(t, assetData["code"], retrievedAsset["code"]) - - // Step 4: Mint tokens (if applicable) - mintData := map[string]interface{}{ - "amount": "1000", - "recipient": testUser.PublicKey, - "asset_id": assetID, - } - - resp, err = setup.MakeRequest("POST", "/api/v1/assets/mint", mintData, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Step 5: Check user balance - resp, err = setup.MakeRequest("GET", fmt.Sprintf("/api/v1/users/%s/balance", testUser.PublicKey), nil, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var balance map[string]interface{} - err = json.NewDecoder(resp.Body).Decode(&balance) - require.NoError(t, err) - - // Verify balance includes the minted tokens - balances, ok := balance["balances"].([]interface{}) - require.True(t, ok) - found := false - for _, b := range balances { - balanceMap := b.(map[string]interface{}) - if balanceMap["asset_code"] == assetData["code"] { - found = true - assert.Equal(t, "1000", balanceMap["balance"]) - break - } - } - assert.True(t, found, "Minted tokens not found in user balance") - - t.Logf("✅ Asset tokenization flow completed successfully for asset %s", assetID) -} - -// TestAssetTokenizationWithInvalidData tests tokenization with invalid data -func TestAssetTokenizationWithInvalidData(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - testUser := setup.CreateTestUser(t) - - // Test with missing required fields - invalidAssetData := map[string]interface{}{ - "name": "", // Empty name should fail - "code": "INVALID", - } - - resp, err := setup.MakeRequest("POST", "/api/v1/assets", invalidAssetData, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) - - // Test with duplicate asset code - assetData := map[string]interface{}{ - "name": "Test Asset", - "code": "DUPLICATE", - "description": "Test description", - "total_supply": "1000000", - "decimals": 7, - } - - // Create first asset - resp, err = setup.MakeRequest("POST", "/api/v1/assets", assetData, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - // Try to create duplicate - resp, err = setup.MakeRequest("POST", "/api/v1/assets", assetData, testUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusConflict, resp.StatusCode) - - t.Log("✅ Asset tokenization validation tests passed") -} - -// TestAssetTokenizationPermissions tests permission requirements -func TestAssetTokenizationPermissions(t *testing.T) { - setup := NewTestSetup(t) - defer setup.Cleanup() - - // Test without authentication - assetData := map[string]interface{}{ - "name": "Unauthorized Asset", - "code": "UNAUTH", - "description": "Should fail without auth", - "total_supply": "1000000", - "decimals": 7, - } - - resp, err := setup.MakeRequest("POST", "/api/v1/assets", assetData, "") - require.NoError(t, err) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) - - // Test without KYC approval - unverifiedUser := setup.CreateTestUser(t) - // Don't approve KYC - - resp, err = setup.MakeRequest("POST", "/api/v1/assets", assetData, unverifiedUser.Token) - require.NoError(t, err) - require.Equal(t, http.StatusForbidden, resp.StatusCode) - - t.Log("✅ Asset tokenization permission tests passed") -} diff --git a/backend/validator/validator_test.go b/backend/validator/validator_test.go index 7b498c9..59b1da3 100644 --- a/backend/validator/validator_test.go +++ b/backend/validator/validator_test.go @@ -19,7 +19,7 @@ func TestValidateTokenizeAssetRequest(t *testing.T) { } req := TokenizeAssetRequest{ - IssuerAccount: "GD6WU5I6OIPRZ4A5I3G6JQ4RG5K27SQ26WPQ5W3MXV6QABBT3C7FIEIF", + IssuerAccount: "GABXYMNLGGTWAV7EQYHVWLQJ7MSAKBW3OX4J5B3UALQTVOUGX3HXBMEM", Name: "Real Asset", Symbol: "RWA1", AssetType: "real_estate", @@ -37,7 +37,7 @@ func TestValidateTokenizeAssetRequestRejectsHtml(t *testing.T) { } req := TokenizeAssetRequest{ - IssuerAccount: "GD6WU5I6OIPRZ4A5I3G6JQ4RG5K27SQ26WPQ5W3MXV6QABBT3C7FIEIF", + IssuerAccount: "GABXYMNLGGTWAV7EQYHVWLQJ7MSAKBW3OX4J5B3UALQTVOUGX3HXBMEM", Name: "Real Asset", Symbol: "RWA1", AssetType: "real_estate", From 0d2edc179363dbf862c03d83c2c17dcddcd8faf7 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 25 Jun 2026 14:08:22 +0100 Subject: [PATCH 2/4] feat: implement real-time blockchain event indexer with checkpoints and retries --- backend/config/config.go | 3 + .../sql/0027_create_event_index.down.sql | 2 + .../sql/0027_create_event_index.up.sql | 29 ++ backend/models/indexed_event.go | 42 ++ backend/router/router.go | 4 + backend/services/event_indexer.go | 443 ++++++++++++++++++ backend/services/event_indexer_test.go | 119 +++++ 7 files changed, 642 insertions(+) create mode 100644 backend/migrations/sql/0027_create_event_index.down.sql create mode 100644 backend/migrations/sql/0027_create_event_index.up.sql create mode 100644 backend/models/indexed_event.go create mode 100644 backend/services/event_indexer.go create mode 100644 backend/services/event_indexer_test.go diff --git a/backend/config/config.go b/backend/config/config.go index 23f97f2..4b5c8b1 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -53,6 +53,9 @@ func InitDB() (*gorm.DB, error) { &models.ComplianceAuditLog{}, // Batch transaction model (#106) &models.BatchTransaction{}, + // Event indexer models (#180) + &models.IndexedEvent{}, + &models.EventCheckpoint{}, ); err != nil { return nil, fmt.Errorf("failed to auto-migrate models: %w", err) } diff --git a/backend/migrations/sql/0027_create_event_index.down.sql b/backend/migrations/sql/0027_create_event_index.down.sql new file mode 100644 index 0000000..a95b98b --- /dev/null +++ b/backend/migrations/sql/0027_create_event_index.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS indexed_events; +DROP TABLE IF EXISTS event_checkpoints; diff --git a/backend/migrations/sql/0027_create_event_index.up.sql b/backend/migrations/sql/0027_create_event_index.up.sql new file mode 100644 index 0000000..a4209b2 --- /dev/null +++ b/backend/migrations/sql/0027_create_event_index.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS indexed_events ( + id BIGSERIAL PRIMARY KEY, + contract_id VARCHAR(56) NOT NULL, + ledger INT NOT NULL, + ledger_closed_at TIMESTAMPTZ NOT NULL, + tx_hash VARCHAR(64) NOT NULL, + event_id VARCHAR(255) NOT NULL UNIQUE, + topic TEXT NOT NULL, + value TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + retry_count INT NOT NULL DEFAULT 0, + error_details TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS event_checkpoints ( + id BIGSERIAL PRIMARY KEY, + checkpoint VARCHAR(100) NOT NULL UNIQUE, + last_ledger INT NOT NULL, + cursor VARCHAR(255), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_indexed_events_contract_id ON indexed_events (contract_id); +CREATE INDEX IF NOT EXISTS idx_indexed_events_ledger ON indexed_events (ledger); +CREATE INDEX IF NOT EXISTS idx_indexed_events_status ON indexed_events (status); +CREATE INDEX IF NOT EXISTS idx_indexed_events_deleted_at ON indexed_events (deleted_at); diff --git a/backend/models/indexed_event.go b/backend/models/indexed_event.go new file mode 100644 index 0000000..980cef4 --- /dev/null +++ b/backend/models/indexed_event.go @@ -0,0 +1,42 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type EventStatus string + +const ( + EventStatusPending EventStatus = "pending" + EventStatusProcessed EventStatus = "processed" + EventStatusFailed EventStatus = "failed" +) + +// IndexedEvent represents a blockchain event captured by the indexer +type IndexedEvent struct { + ID uint `gorm:"primaryKey" json:"id"` + ContractID string `gorm:"type:varchar(56);index" json:"contract_id"` + Ledger uint32 `gorm:"index" json:"ledger"` + LedgerClosedAt time.Time `json:"ledger_closed_at"` + TxHash string `gorm:"type:varchar(64)" json:"tx_hash"` + EventID string `gorm:"type:varchar(255);uniqueIndex" json:"event_id"` + Topic string `gorm:"type:text" json:"topic"` // JSON array of topics + Value string `gorm:"type:text" json:"value"` // JSON value + Status EventStatus `gorm:"type:varchar(20);default:'pending';index" json:"status"` + RetryCount int `gorm:"default:0" json:"retry_count"` + ErrorDetails string `gorm:"type:text" json:"error_details,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// EventCheckpoint tracks the sync state of the indexer +type EventCheckpoint struct { + ID uint `gorm:"primaryKey" json:"id"` + Checkpoint string `gorm:"type:varchar(100);uniqueIndex" json:"checkpoint"` // Name/Key of the checkpoint, e.g. "global_stellar_indexer" + LastLedger uint32 `json:"last_ledger"` + Cursor string `gorm:"type:varchar(255)" json:"cursor"` // Paging token or cursor if applicable + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/router/router.go b/backend/router/router.go index 5f8019f..af40fe0 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -326,6 +326,10 @@ func SetupRouter(db *gorm.DB) *gin.Engine { // Pre-launch the hub _ = handlers.GetHub() + // Initialize and start event indexer (#180) + eventIndexer := services.NewEventIndexer(db) + eventIndexer.Start() + return router } diff --git a/backend/services/event_indexer.go b/backend/services/event_indexer.go new file mode 100644 index 0000000..413f7f6 --- /dev/null +++ b/backend/services/event_indexer.go @@ -0,0 +1,443 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/yourusername/kor-assetforge/models" + "gorm.io/gorm" +) + +type EventHandler func(ctx context.Context, event *models.IndexedEvent) error + +type EventIndexer struct { + db *gorm.DB + rpcURL string + pollInterval time.Duration + maxRetries int + handlers map[string]map[string]EventHandler // contractID -> topic -> handler + handlersMu sync.RWMutex + eventQueue chan *models.IndexedEvent + workersCount int + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + running bool + runningMu sync.Mutex +} + +type GetEventsRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params GetEventsParams `json:"params"` +} + +type GetEventsParams struct { + StartLedger uint32 `json:"startLedger"` + Filters []GetEventsFilter `json:"filters,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type GetEventsFilter struct { + Type string `json:"type"` + ContractIds []string `json:"contractIds,omitempty"` + Topics []string `json:"topics,omitempty"` +} + +type GetEventsResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result *GetEventsResult `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type GetEventsResult struct { + LatestLedger uint32 `json:"latestLedger"` + Events []SorobanEvent `json:"events"` +} + +type SorobanEvent struct { + Type string `json:"type"` + Ledger uint32 `json:"ledger"` + LedgerClosedAt string `json:"ledgerClosedAt"` + ID string `json:"id"` + ContractID string `json:"contractId"` + Topic []string `json:"topic"` + Value struct { + XDR string `json:"xdr"` + } `json:"value"` + TxHash string `json:"txHash"` +} + +// NewEventIndexer creates a new EventIndexer instance +func NewEventIndexer(db *gorm.DB) *EventIndexer { + rpcURL := os.Getenv("STELLAR_RPC_URL") + if rpcURL == "" { + rpcURL = "https://soroban-testnet.stellar.org" // fallback + } + + pollStr := os.Getenv("EVENT_INDEXER_POLL_INTERVAL") + pollInterval := 5 * time.Second + if pollStr != "" { + if d, err := time.ParseDuration(pollStr); err == nil { + pollInterval = d + } + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &EventIndexer{ + db: db, + rpcURL: rpcURL, + pollInterval: pollInterval, + maxRetries: 5, + handlers: make(map[string]map[string]EventHandler), + eventQueue: make(chan *models.IndexedEvent, 1000), + workersCount: 3, + ctx: ctx, + cancel: cancel, + } +} + +// RegisterHandler registers a handler for specific contract events +func (ei *EventIndexer) RegisterHandler(contractID string, topic string, handler EventHandler) { + ei.handlersMu.Lock() + defer ei.handlersMu.Unlock() + + if _, ok := ei.handlers[contractID]; !ok { + ei.handlers[contractID] = make(map[string]EventHandler) + } + ei.handlers[contractID][topic] = handler +} + +// Start runs the indexer service in the background +func (ei *EventIndexer) Start() { + ei.runningMu.Lock() + if ei.running { + ei.runningMu.Unlock() + return + } + ei.running = true + ei.runningMu.Unlock() + + log.Printf("EventIndexer: Starting indexer with RPC %s", ei.rpcURL) + + // Start workers + for i := 0; i < ei.workersCount; i++ { + ei.wg.Add(1) + go ei.worker(i) + } + + // Start poller + ei.wg.Add(1) + go ei.poller() +} + +// Stop stops the indexer service +func (ei *EventIndexer) Stop() { + ei.runningMu.Lock() + if !ei.running { + ei.runningMu.Unlock() + return + } + ei.running = false + ei.runningMu.Unlock() + + log.Println("EventIndexer: Stopping indexer...") + ei.cancel() + close(ei.eventQueue) + ei.wg.Wait() + log.Println("EventIndexer: Indexer stopped.") +} + +// getCheckpoint loads or creates the checkpoint record +func (ei *EventIndexer) getCheckpoint() (*models.EventCheckpoint, error) { + var cp models.EventCheckpoint + err := ei.db.Where("checkpoint = ?", "global_stellar_indexer").First(&cp).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + // Get current network ledger or start from 1 + startLedger := uint32(1) + cp = models.EventCheckpoint{ + Checkpoint: "global_stellar_indexer", + LastLedger: startLedger, + Cursor: "", + } + if createErr := ei.db.Create(&cp).Error; createErr != nil { + return nil, createErr + } + return &cp, nil + } + return nil, err + } + return &cp, nil +} + +// updateCheckpoint saves the checkpoint record +func (ei *EventIndexer) updateCheckpoint(ledger uint32, cursor string) error { + return ei.db.Model(&models.EventCheckpoint{}). + Where("checkpoint = ?", "global_stellar_indexer"). + Updates(map[string]interface{}{ + "last_ledger": ledger, + "cursor": cursor, + "updated_at": time.Now(), + }).Error +} + +func (ei *EventIndexer) poller() { + defer ei.wg.Done() + + ticker := time.NewTicker(ei.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ei.ctx.Done(): + return + case <-ticker.C: + checkpoint, err := ei.getCheckpoint() + if err != nil { + log.Printf("EventIndexer Error: Failed to fetch checkpoint: %v", err) + continue + } + + events, latestRPCledger, err := ei.fetchEvents(checkpoint.LastLedger) + if err != nil { + log.Printf("EventIndexer Warning: Failed to fetch events from RPC: %v", err) + continue + } + + if len(events) > 0 { + log.Printf("EventIndexer: Fetched %d new blockchain events", len(events)) + } + + lastProcessedLedger := checkpoint.LastLedger + for _, sev := range events { + // Convert to GORM model + topicJSON, _ := json.Marshal(sev.Topic) + closedAt, parseErr := time.Parse(time.RFC3339, sev.LedgerClosedAt) + if parseErr != nil { + closedAt = time.Now() + } + + dbEvent := &models.IndexedEvent{ + ContractID: sev.ContractID, + Ledger: sev.Ledger, + LedgerClosedAt: closedAt, + TxHash: sev.TxHash, + EventID: sev.ID, + Topic: string(topicJSON), + Value: sev.Value.XDR, + Status: models.EventStatusPending, + } + + // Check for duplicates + var existing models.IndexedEvent + if err := ei.db.Where("event_id = ?", dbEvent.EventID).First(&existing).Error; err == nil { + // Duplicate event, skip + continue + } + + // Save to database + if err := ei.db.Create(dbEvent).Error; err != nil { + log.Printf("EventIndexer Error: Failed to save event to DB: %v", err) + continue + } + + // Enqueue for processing + select { + case ei.eventQueue <- dbEvent: + case <-ei.ctx.Done(): + return + } + + if sev.Ledger > lastProcessedLedger { + lastProcessedLedger = sev.Ledger + } + } + + // Update checkpoint to either the last processed ledger or the latest ledger returned by RPC + targetLedger := lastProcessedLedger + if latestRPCledger > targetLedger { + targetLedger = latestRPCledger + } + if targetLedger > checkpoint.LastLedger { + if err := ei.updateCheckpoint(targetLedger, ""); err != nil { + log.Printf("EventIndexer Error: Failed to update checkpoint: %v", err) + } + } + } + } +} + +func (ei *EventIndexer) fetchEvents(startLedger uint32) ([]SorobanEvent, uint32, error) { + // Construct the JSON-RPC request payload + reqBody := GetEventsRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "getEvents", + Params: GetEventsParams{ + StartLedger: startLedger, + Limit: 100, + }, + } + + // Fetch contract IDs to filter, if any are registered + ei.handlersMu.RLock() + var contractIDs []string + for cid := range ei.handlers { + contractIDs = append(contractIDs, cid) + } + ei.handlersMu.RUnlock() + + if len(contractIDs) > 0 { + reqBody.Params.Filters = []GetEventsFilter{ + { + Type: "contract", + ContractIds: contractIDs, + }, + } + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, 0, err + } + + req, err := http.NewRequestWithContext(ei.ctx, "POST", ei.rpcURL, bytes.NewBuffer(payload)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, err + } + + var rpcResp GetEventsResponse + if err := json.Unmarshal(body, &rpcResp); err != nil { + return nil, 0, err + } + + if rpcResp.Error != nil { + return nil, 0, fmt.Errorf("RPC error (%d): %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + if rpcResp.Result == nil { + return nil, 0, nil + } + + return rpcResp.Result.Events, rpcResp.Result.LatestLedger, nil +} + +func (ei *EventIndexer) worker(id int) { + defer ei.wg.Done() + + for ev := range ei.eventQueue { + ei.processEvent(ev) + } +} + +func (ei *EventIndexer) processEvent(ev *models.IndexedEvent) { + // Parse topics to identify correct handler + var topics []string + if err := json.Unmarshal([]byte(ev.Topic), &topics); err != nil { + ei.markFailed(ev, fmt.Sprintf("failed to parse topic JSON: %v", err)) + return + } + + if len(topics) == 0 { + ei.markProcessed(ev) + return + } + + primaryTopic := topics[0] + + ei.handlersMu.RLock() + var handler EventHandler + if contractHandlers, ok := ei.handlers[ev.ContractID]; ok { + handler = contractHandlers[primaryTopic] + } + ei.handlersMu.RUnlock() + + if handler == nil { + // No handler registered for this event topic, mark processed anyway + ei.markProcessed(ev) + return + } + + // Process event with retry logic + var processErr error + backoff := 500 * time.Millisecond + + for i := 0; i <= ei.maxRetries; i++ { + if i > 0 { + time.Sleep(backoff) + backoff *= 2 + } + + ctx, cancel := context.WithTimeout(ei.ctx, 10*time.Second) + processErr = handler(ctx, ev) + cancel() + + if processErr == nil { + break + } + + log.Printf("EventIndexer: Worker failed to process event %s (attempt %d/%d): %v", ev.EventID, i+1, ei.maxRetries+1, processErr) + } + + if processErr != nil { + ei.markFailed(ev, processErr.Error()) + } else { + ei.markProcessed(ev) + } +} + +func (ei *EventIndexer) markProcessed(ev *models.IndexedEvent) { + err := ei.db.Model(ev).Updates(map[string]interface{}{ + "status": models.EventStatusProcessed, + "updated_at": time.Now(), + }).Error + if err != nil { + log.Printf("EventIndexer Error: Failed to update event status: %v", err) + } +} + +func (ei *EventIndexer) markFailed(ev *models.IndexedEvent, errMsg string) { + err := ei.db.Model(ev).Updates(map[string]interface{}{ + "status": models.EventStatusFailed, + "retry_count": ev.RetryCount + 1, + "error_details": errMsg, + "updated_at": time.Now(), + }).Error + if err != nil { + log.Printf("EventIndexer Error: Failed to update failed event status: %v", err) + } +} diff --git a/backend/services/event_indexer_test.go b/backend/services/event_indexer_test.go new file mode 100644 index 0000000..aeac7c1 --- /dev/null +++ b/backend/services/event_indexer_test.go @@ -0,0 +1,119 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yourusername/kor-assetforge/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestEventIndexer_SyncAndProcess(t *testing.T) { + // Set up database + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + err = db.AutoMigrate(&models.IndexedEvent{}, &models.EventCheckpoint{}) + require.NoError(t, err) + + // Mock RPC response + mockEvents := []SorobanEvent{ + { + Type: "contract", + Ledger: 100, + LedgerClosedAt: time.Now().Format(time.RFC3339), + ID: "0000000000000000100-0000000001", + ContractID: "C1111111111111111111111111111111111111111111111111111111", + Topic: []string{"transfer", "alice", "bob"}, + TxHash: "txhash123", + }, + } + mockEvents[0].Value.XDR = "AAAAAAA=" + + var handlerCalled bool + var handlerMu sync.Mutex + var wg sync.WaitGroup + wg.Add(1) + + // Start mock RPC server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var req GetEventsRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + + resp := GetEventsResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: &GetEventsResult{ + LatestLedger: 105, + Events: mockEvents, + }, + } + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer server.Close() + + // Initialize EventIndexer + indexer := NewEventIndexer(db) + indexer.rpcURL = server.URL + indexer.pollInterval = 100 * time.Millisecond + + // Register event handler + indexer.RegisterHandler("C1111111111111111111111111111111111111111111111111111111", "transfer", func(ctx context.Context, ev *models.IndexedEvent) error { + handlerMu.Lock() + defer handlerMu.Unlock() + handlerCalled = true + wg.Done() + return nil + }) + + // Start indexing + indexer.Start() + defer indexer.Stop() + + // Wait for handler to be invoked + c := make(chan struct{}) + go func() { + wg.Wait() + close(c) + }() + + select { + case <-c: + // Success + case <-time.After(3 * time.Second): + t.Fatal("Timeout waiting for handler to be called") + } + + handlerMu.Lock() + assert.True(t, handlerCalled) + handlerMu.Unlock() + + // Check database records + var ev models.IndexedEvent + err = db.Where("event_id = ?", mockEvents[0].ID).First(&ev).Error + require.NoError(t, err) + assert.Equal(t, models.EventStatusProcessed, ev.Status) + assert.Equal(t, uint32(100), ev.Ledger) + assert.Equal(t, "txhash123", ev.TxHash) + + // Check checkpoint + var cp models.EventCheckpoint + err = db.Where("checkpoint = ?", "global_stellar_indexer").First(&cp).Error + require.NoError(t, err) + assert.Equal(t, uint32(105), cp.LastLedger) +} From be40dd096dcf086b0af9c1633dcf8486d31d6810 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 26 Jun 2026 09:08:39 +0100 Subject: [PATCH 3/4] feat: implement liquidity pool analytics and comparison endpoints --- backend/handlers/liquidity.go | 70 +++++++++++++ backend/handlers/liquidity_test.go | 128 +++++++++++++++++++++++ backend/models/pool_metrics.go | 30 ++++++ backend/router/router.go | 2 + backend/services/pool_analytics.go | 160 +++++++++++++++++++++++++++++ 5 files changed, 390 insertions(+) create mode 100644 backend/handlers/liquidity_test.go create mode 100644 backend/models/pool_metrics.go create mode 100644 backend/services/pool_analytics.go diff --git a/backend/handlers/liquidity.go b/backend/handlers/liquidity.go index d7a4d88..4d1f5f3 100644 --- a/backend/handlers/liquidity.go +++ b/backend/handlers/liquidity.go @@ -1,6 +1,8 @@ package handlers import ( + "errors" + "fmt" "math" "net/http" "time" @@ -8,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/yourusername/kor-assetforge/apperrors" "github.com/yourusername/kor-assetforge/models" + "github.com/yourusername/kor-assetforge/services" "github.com/yourusername/kor-assetforge/utils" "gorm.io/gorm" ) @@ -530,3 +533,70 @@ func (h *LiquidityHandler) GetSwapHistory(c *gin.Context) { } c.JSON(http.StatusOK, paginationRes) } + +// GetPoolAnalytics retrieves computed pool analytics/metrics +// @Summary Get pool analytics +// @Description Get computed analytics (APY, volume, TVL, impermanent loss) for a liquidity pool +// @Tags liquidity +// @Param id path int true "Pool ID" +// @Success 200 {object} models.PoolMetrics +// @Router /liquidity/pools/{id}/analytics [get] +func (h *LiquidityHandler) GetPoolAnalytics(c *gin.Context) { + var uri struct { + ID uint `uri:"id" binding:"required,gt=0"` + } + if err := c.ShouldBindUri(&uri); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pool ID"}) + return + } + + analyticsService := services.NewPoolAnalyticsService(h.db) + metrics, err := analyticsService.CalculatePoolMetrics(uri.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Pool not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, metrics) +} + +// ComparePools compares two liquidity pools +// @Summary Compare pools +// @Description Compare APY, TVL, and metrics of two liquidity pools +// @Tags liquidity +// @Param pool_a query int true "Pool A ID" +// @Param pool_b query int true "Pool B ID" +// @Success 200 {object} models.PoolComparison +// @Router /liquidity/pools/compare [get] +func (h *LiquidityHandler) ComparePools(c *gin.Context) { + poolAStr := c.Query("pool_a") + poolBStr := c.Query("pool_b") + + if poolAStr == "" || poolBStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "pool_a and pool_b query parameters are required"}) + return + } + + var poolAID, poolBID uint + if _, err := fmt.Sscanf(poolAStr, "%d", &poolAID); err != nil || poolAID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pool_a ID"}) + return + } + if _, err := fmt.Sscanf(poolBStr, "%d", &poolBID); err != nil || poolBID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pool_b ID"}) + return + } + + analyticsService := services.NewPoolAnalyticsService(h.db) + comparison, err := analyticsService.ComparePools(poolAID, poolBID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, comparison) +} diff --git a/backend/handlers/liquidity_test.go b/backend/handlers/liquidity_test.go new file mode 100644 index 0000000..8122c15 --- /dev/null +++ b/backend/handlers/liquidity_test.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yourusername/kor-assetforge/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestLiquidityHandler_AnalyticsAndComparison(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Set up memory DB + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + err = db.AutoMigrate( + &models.Asset{}, + &models.LiquidityPool{}, + &models.LiquidityPosition{}, + &models.PoolSwap{}, + ) + require.NoError(t, err) + + // Seed assets + assetA := models.Asset{Name: "Asset A", Symbol: "ASST-A", TotalSupply: 1000000, ContractID: "ASSET-A"} + assetB := models.Asset{Name: "Asset B", Symbol: "ASST-B", TotalSupply: 2000000, ContractID: "ASSET-B"} + db.Create(&assetA) + db.Create(&assetB) + + // Seed pools + pool1 := models.LiquidityPool{ + AssetAID: assetA.ID, + AssetBID: assetB.ID, + ReserveA: 100000, + ReserveB: 200000, + TotalLPTokens: 150000, + FeeBasisPoints: 30, + CreatorAddress: "G-CREATOR-1", + Active: true, + } + pool2 := models.LiquidityPool{ + AssetAID: assetA.ID, + AssetBID: assetB.ID, + ReserveA: 50000, + ReserveB: 100000, + TotalLPTokens: 75000, + FeeBasisPoints: 30, + CreatorAddress: "G-CREATOR-2", + Active: true, + } + db.Create(&pool1) + db.Create(&pool2) + + // Seed position + position := models.LiquidityPosition{ + PoolID: pool1.ID, + ProviderAddress: "G-PROVIDER-1", + LPTokens: 50000, + DepositedA: 30000, + DepositedB: 50000, + } + db.Create(&position) + + // Seed swap (7d volume) + swap := models.PoolSwap{ + PoolID: pool1.ID, + TraderAddress: "G-TRADER-1", + InputAssetID: assetA.ID, + OutputAssetID: assetB.ID, + InputAmount: 1000, + OutputAmount: 2000, + FeeAmount: 3, + CreatedAt: time.Now().Add(-10 * time.Minute), + } + db.Create(&swap) + + handler := NewLiquidityHandler(db) + + t.Run("GetPoolAnalytics", func(t *testing.T) { + r := gin.New() + r.GET("/pools/:id/analytics", handler.GetPoolAnalytics) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/pools/1/analytics", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var metrics models.PoolMetrics + err = json.Unmarshal(w.Body.Bytes(), &metrics) + require.NoError(t, err) + + assert.Equal(t, pool1.ID, metrics.PoolID) + assert.Equal(t, int64(1000), metrics.Volume24h) + assert.Equal(t, int64(3), metrics.Fees24h) + assert.Equal(t, pool1.ReserveB*2, metrics.TVL) + assert.True(t, metrics.APY > 0) + assert.True(t, metrics.ImpermanentLoss > 0) + }) + + t.Run("ComparePools", func(t *testing.T) { + r := gin.New() + r.GET("/pools/compare", handler.ComparePools) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/pools/compare?pool_a=1&pool_b=2", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var comp models.PoolComparison + err = json.Unmarshal(w.Body.Bytes(), &comp) + require.NoError(t, err) + + assert.Equal(t, pool1.ID, comp.PoolA.ID) + assert.Equal(t, pool2.ID, comp.PoolB.ID) + assert.True(t, comp.TVLDifference > 0) + }) +} diff --git a/backend/models/pool_metrics.go b/backend/models/pool_metrics.go new file mode 100644 index 0000000..819d2b3 --- /dev/null +++ b/backend/models/pool_metrics.go @@ -0,0 +1,30 @@ +package models + +import ( + "time" +) + +// PoolMetrics represents computed analytics for a liquidity pool +type PoolMetrics struct { + PoolID uint `json:"pool_id"` + Volume24h int64 `json:"volume_24h"` // in stroops of input asset(s) + Volume7d int64 `json:"volume_7d"` + Volume30d int64 `json:"volume_30d"` + Fees24h int64 `json:"fees_24h"` // in stroops of input asset(s) + Fees7d int64 `json:"fees_7d"` + Fees30d int64 `json:"fees_30d"` + TVL int64 `json:"tvl"` // Total Value Locked in stroops (ReserveA + ReserveB equivalent) + APY float64 `json:"apy"` // Annual Percentage Yield (percentage, e.g. 12.5 means 12.5%) + ImpermanentLoss float64 `json:"impermanent_loss"` // impermanent loss ratio (e.g. 0.05 means 5%) + UpdatedAt time.Time `json:"updated_at"` +} + +// PoolComparison represents comparative data between two pools +type PoolComparison struct { + PoolA LiquidityPool `json:"pool_a"` + PoolB LiquidityPool `json:"pool_b"` + MetricsA PoolMetrics `json:"metrics_a"` + MetricsB PoolMetrics `json:"metrics_b"` + APYDifference float64 `json:"apy_difference"` // MetricsA.APY - MetricsB.APY + TVLDifference int64 `json:"tvl_difference"` // MetricsA.TVL - MetricsB.TVL +} diff --git a/backend/router/router.go b/backend/router/router.go index af40fe0..9b0a16a 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -259,6 +259,8 @@ func SetupRouter(db *gorm.DB) *gin.Engine { v1.POST("/liquidity/pools", liquidityHandler.CreatePool) v1.GET("/liquidity/pools", liquidityHandler.ListPools) v1.GET("/liquidity/pools/:id", liquidityHandler.GetPool) + v1.GET("/liquidity/pools/:id/analytics", liquidityHandler.GetPoolAnalytics) + v1.GET("/liquidity/pools/compare", liquidityHandler.ComparePools) v1.POST("/liquidity/add", liquidityHandler.AddLiquidity) v1.POST("/liquidity/remove", liquidityHandler.RemoveLiquidity) v1.POST("/liquidity/swap", liquidityHandler.Swap) diff --git a/backend/services/pool_analytics.go b/backend/services/pool_analytics.go new file mode 100644 index 0000000..c1f1b3c --- /dev/null +++ b/backend/services/pool_analytics.go @@ -0,0 +1,160 @@ +package services + +import ( + "math" + "time" + + "github.com/yourusername/kor-assetforge/models" + "gorm.io/gorm" +) + +type PoolAnalyticsService struct { + db *gorm.DB +} + +// NewPoolAnalyticsService creates a new pool analytics service +func NewPoolAnalyticsService(db *gorm.DB) *PoolAnalyticsService { + return &PoolAnalyticsService{db: db} +} + +// CalculatePoolMetrics calculates the APY, TVL, volume, and fees for a pool +func (s *PoolAnalyticsService) CalculatePoolMetrics(poolID uint) (*models.PoolMetrics, error) { + var pool models.LiquidityPool + if err := s.db.First(&pool, poolID).Error; err != nil { + return nil, err + } + + now := time.Now() + + // Compute volumes and fees + vol24h, fee24h, err := s.getVolumeAndFees(poolID, now.Add(-24*time.Hour)) + if err != nil { + return nil, err + } + vol7d, fee7d, err := s.getVolumeAndFees(poolID, now.Add(-7*24*time.Hour)) + if err != nil { + return nil, err + } + vol30d, fee30d, err := s.getVolumeAndFees(poolID, now.Add(-30*24*time.Hour)) + if err != nil { + return nil, err + } + + // TVL: 2 * ReserveB in terms of Asset B, or ReserveA + ReserveB if they are 1:1. + // For standard metric calculations, we use 2 * ReserveB + tvl := pool.ReserveA + pool.ReserveB + if pool.ReserveB > 0 { + tvl = pool.ReserveB * 2 + } + + // APY = (annualized fees / TVL) * 100 + var apy float64 + if tvl > 0 { + // Annualize 7d fees + annualizedFees := float64(fee7d) * 52.14 + apy = (annualizedFees / float64(tvl)) * 100.0 + } + + // Average impermanent loss across all positions + var avgIL float64 + var positions []models.LiquidityPosition + if err := s.db.Where("pool_id = ?", poolID).Find(&positions).Error; err == nil && len(positions) > 0 { + var sumIL float64 + var count int + for _, pos := range positions { + il, err := s.CalculatePositionImpermanentLoss(pos.ID) + if err == nil { + sumIL += il + count++ + } + } + if count > 0 { + avgIL = sumIL / float64(count) + } + } + + return &models.PoolMetrics{ + PoolID: poolID, + Volume24h: vol24h, + Volume7d: vol7d, + Volume30d: vol30d, + Fees24h: fee24h, + Fees7d: fee7d, + Fees30d: fee30d, + TVL: tvl, + APY: apy, + ImpermanentLoss: avgIL, + UpdatedAt: now, + }, nil +} + +// CalculatePositionImpermanentLoss calculates impermanent loss for a specific position +func (s *PoolAnalyticsService) CalculatePositionImpermanentLoss(positionID uint) (float64, error) { + var pos models.LiquidityPosition + if err := s.db.Preload("Pool").First(&pos, positionID).Error; err != nil { + return 0, err + } + + if pos.DepositedA == 0 || pos.DepositedB == 0 || pos.Pool.ReserveA == 0 || pos.Pool.ReserveB == 0 { + return 0, nil + } + + // Initial price ratio: DepositedB / DepositedA + initialPriceRatio := float64(pos.DepositedB) / float64(pos.DepositedA) + + // Current price ratio: ReserveB / ReserveA + currentPriceRatio := float64(pos.Pool.ReserveB) / float64(pos.Pool.ReserveA) + + // Price ratio change factor k + k := currentPriceRatio / initialPriceRatio + + // Impermanent Loss formula: (2 * sqrt(k)) / (1 + k) - 1 + il := (2.0 * math.Sqrt(k)) / (1.0 + k) - 1.0 + + // We return the absolute loss or negative percentage + return math.Abs(il), nil +} + +// ComparePools compares two liquidity pools and calculates differences +func (s *PoolAnalyticsService) ComparePools(poolAID, poolBID uint) (*models.PoolComparison, error) { + var poolA, poolB models.LiquidityPool + if err := s.db.First(&poolA, poolAID).Error; err != nil { + return nil, err + } + if err := s.db.First(&poolB, poolBID).Error; err != nil { + return nil, err + } + + metricsA, err := s.CalculatePoolMetrics(poolAID) + if err != nil { + return nil, err + } + + metricsB, err := s.CalculatePoolMetrics(poolBID) + if err != nil { + return nil, err + } + + return &models.PoolComparison{ + PoolA: poolA, + PoolB: poolB, + MetricsA: *metricsA, + MetricsB: *metricsB, + APYDifference: metricsA.APY - metricsB.APY, + TVLDifference: metricsA.TVL - metricsB.TVL, + }, nil +} + +func (s *PoolAnalyticsService) getVolumeAndFees(poolID uint, since time.Time) (int64, int64, error) { + var result struct { + Volume int64 `gorm:"column:volume"` + Fees int64 `gorm:"column:fees"` + } + + err := s.db.Model(&models.PoolSwap{}). + Where("pool_id = ? AND created_at >= ?", poolID, since). + Select("COALESCE(SUM(input_amount), 0) as volume, COALESCE(SUM(fee_amount), 0) as fees"). + Scan(&result).Error + + return result.Volume, result.Fees, err +} From 9bdaccaa02120cfd67cd232636b147b11a6f5796 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 14:55:16 +0100 Subject: [PATCH 4/4] Add dynamic fee configuration with admin interface (#177) - Add FeeConfig and FeeAuditLog models with GORM soft-delete support - Add SQL migration 0013 creating fee_configs and fee_audit_logs tables - Implement FeeService with tiered volume discount calculation using basis points, configurable per fee type (marketplace, transfer, staking, liquidity) - Implement FeeAdminHandler with full CRUD for admin users, audit log endpoint, and public preview/active-config endpoints - Register FeeConfig and FeeAuditLog in AutoMigrate - Wire admin fee routes under authenticated admin group in router - Wire public /fees/preview and /fees/active into v1 group - Add unit tests covering create, list, update, deactivate, tiered discounts, fee preview with and without tier, and validation error cases --- backend/config/config.go | 5 + backend/handlers/fee_admin.go | 378 ++++++++++++++++++ backend/handlers/fee_admin_test.go | 294 ++++++++++++++ .../sql/0013_create_fee_config.down.sql | 2 + .../sql/0013_create_fee_config.up.sql | 37 ++ backend/models/fee_config.go | 71 ++++ backend/router/router.go | 25 +- backend/services/fee_service.go | 300 ++++++++++++++ 8 files changed, 1110 insertions(+), 2 deletions(-) create mode 100644 backend/handlers/fee_admin.go create mode 100644 backend/handlers/fee_admin_test.go create mode 100644 backend/migrations/sql/0013_create_fee_config.down.sql create mode 100644 backend/migrations/sql/0013_create_fee_config.up.sql create mode 100644 backend/models/fee_config.go create mode 100644 backend/services/fee_service.go diff --git a/backend/config/config.go b/backend/config/config.go index 4b5c8b1..7d3b119 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -56,6 +56,11 @@ func InitDB() (*gorm.DB, error) { // Event indexer models (#180) &models.IndexedEvent{}, &models.EventCheckpoint{}, + // GDPR export models (#178) + &models.ExportJob{}, + // Dynamic fee config models (#177) + &models.FeeConfig{}, + &models.FeeAuditLog{}, ); err != nil { return nil, fmt.Errorf("failed to auto-migrate models: %w", err) } diff --git a/backend/handlers/fee_admin.go b/backend/handlers/fee_admin.go new file mode 100644 index 0000000..ecfefa2 --- /dev/null +++ b/backend/handlers/fee_admin.go @@ -0,0 +1,378 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/yourusername/kor-assetforge/models" + "github.com/yourusername/kor-assetforge/services" + "gorm.io/gorm" +) + +// FeeAdminHandler handles admin endpoints for dynamic fee configuration. +type FeeAdminHandler struct { + db *gorm.DB + feeService *services.FeeService +} + +// NewFeeAdminHandler creates a new FeeAdminHandler. +func NewFeeAdminHandler(db *gorm.DB, feeService *services.FeeService) *FeeAdminHandler { + return &FeeAdminHandler{ + db: db, + feeService: feeService, + } +} + +// createFeeConfigRequest is the request body for creating a fee config. +type createFeeConfigRequest struct { + Name string `json:"name" binding:"required"` + FeeType models.FeeType `json:"fee_type" binding:"required"` + BaseBasisPoints int `json:"base_basis_points" binding:"required,min=0"` + MinBasisPoints int `json:"min_basis_points" binding:"min=0"` + MaxBasisPoints int `json:"max_basis_points" binding:"required,min=0"` + VolumeTiers json.RawMessage `json:"volume_tiers"` + EffectiveFrom *time.Time `json:"effective_from"` + EffectiveUntil *time.Time `json:"effective_until"` + Reason string `json:"reason"` +} + +// CreateFeeConfig handles POST /api/v1/admin/fees +// @Summary Create fee configuration (admin) +// @Description Create a new dynamic fee rule with optional volume tiers +// @Tags fees +// @Accept json +// @Produce json +// @Param body body createFeeConfigRequest true "Fee configuration" +// @Success 201 {object} models.FeeConfig +// @Router /admin/fees [post] +func (h *FeeAdminHandler) CreateFeeConfig(c *gin.Context) { + adminID := getRequestingAdminID(c) + + var req createFeeConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tiersJSON := "[]" + if len(req.VolumeTiers) > 0 && string(req.VolumeTiers) != "null" { + tiersJSON = string(req.VolumeTiers) + } + + cfg := &models.FeeConfig{ + Name: req.Name, + FeeType: req.FeeType, + BaseBasisPoints: req.BaseBasisPoints, + MinBasisPoints: req.MinBasisPoints, + MaxBasisPoints: req.MaxBasisPoints, + VolumeTiers: tiersJSON, + Active: true, + EffectiveFrom: resolveTime(req.EffectiveFrom, time.Now()), + EffectiveUntil: req.EffectiveUntil, + } + + if err := h.feeService.CreateFeeConfig(cfg, adminID, req.Reason); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, cfg) +} + +// updateFeeConfigRequest is the body for updating an existing fee config. +type updateFeeConfigRequest struct { + Name string `json:"name" binding:"required"` + FeeType models.FeeType `json:"fee_type" binding:"required"` + BaseBasisPoints int `json:"base_basis_points" binding:"min=0"` + MinBasisPoints int `json:"min_basis_points" binding:"min=0"` + MaxBasisPoints int `json:"max_basis_points" binding:"min=0"` + VolumeTiers json.RawMessage `json:"volume_tiers"` + EffectiveFrom *time.Time `json:"effective_from"` + EffectiveUntil *time.Time `json:"effective_until"` + Active *bool `json:"active"` + Reason string `json:"reason"` +} + +// UpdateFeeConfig handles PUT /api/v1/admin/fees/:id +// @Summary Update fee configuration (admin) +// @Description Modify an existing fee rule +// @Tags fees +// @Accept json +// @Produce json +// @Param id path int true "Fee config ID" +// @Param body body updateFeeConfigRequest true "Updated fee configuration" +// @Success 200 {object} models.FeeConfig +// @Router /admin/fees/{id} [put] +func (h *FeeAdminHandler) UpdateFeeConfig(c *gin.Context) { + adminID := getRequestingAdminID(c) + + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fee config ID"}) + return + } + + var req updateFeeConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tiersJSON := "[]" + if len(req.VolumeTiers) > 0 && string(req.VolumeTiers) != "null" { + tiersJSON = string(req.VolumeTiers) + } + + active := true + if req.Active != nil { + active = *req.Active + } + + updates := &models.FeeConfig{ + Name: req.Name, + FeeType: req.FeeType, + BaseBasisPoints: req.BaseBasisPoints, + MinBasisPoints: req.MinBasisPoints, + MaxBasisPoints: req.MaxBasisPoints, + VolumeTiers: tiersJSON, + Active: active, + EffectiveFrom: resolveTime(req.EffectiveFrom, time.Now()), + EffectiveUntil: req.EffectiveUntil, + } + + cfg, err := h.feeService.UpdateFeeConfig(id, updates, adminID, req.Reason) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "fee config not found" { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, cfg) +} + +// DeactivateFeeConfig handles DELETE /api/v1/admin/fees/:id +// @Summary Deactivate fee configuration (admin) +// @Description Mark a fee rule as inactive (soft-deactivate, not deleted) +// @Tags fees +// @Param id path int true "Fee config ID" +// @Success 200 {object} map[string]string +// @Router /admin/fees/{id} [delete] +func (h *FeeAdminHandler) DeactivateFeeConfig(c *gin.Context) { + adminID := getRequestingAdminID(c) + + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fee config ID"}) + return + } + + var body struct { + Reason string `json:"reason"` + } + _ = c.ShouldBindJSON(&body) + + if err := h.feeService.DeactivateFeeConfig(id, adminID, body.Reason); err != nil { + status := http.StatusInternalServerError + if err.Error() == "fee config not found" { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "fee config deactivated successfully"}) +} + +// ListFeeConfigs handles GET /api/v1/admin/fees +// @Summary List fee configurations (admin) +// @Description Retrieve all fee configs with optional type filter +// @Tags fees +// @Param fee_type query string false "Fee type filter" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} map[string]interface{} +// @Router /admin/fees [get] +func (h *FeeAdminHandler) ListFeeConfigs(c *gin.Context) { + feeType := c.Query("fee_type") + page := queryIntDefault(c, "page", 1) + limit := queryIntDefault(c, "limit", 20) + if limit > 100 { + limit = 100 + } + + configs, total, err := h.feeService.ListFeeConfigs(feeType, page, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": configs, + "total": total, + "page": page, + "limit": limit, + }) +} + +// GetFeeConfig handles GET /api/v1/admin/fees/:id +// @Summary Get fee configuration by ID (admin) +// @Tags fees +// @Param id path int true "Fee config ID" +// @Success 200 {object} models.FeeConfig +// @Router /admin/fees/{id} [get] +func (h *FeeAdminHandler) GetFeeConfig(c *gin.Context) { + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fee config ID"}) + return + } + + var cfg models.FeeConfig + if err := h.db.First(&cfg, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "fee config not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch fee config"}) + return + } + + c.JSON(http.StatusOK, cfg) +} + +// GetFeeAuditLog handles GET /api/v1/admin/fees/:id/audit +// @Summary Get fee config audit log (admin) +// @Tags fees +// @Param id path int true "Fee config ID" +// @Success 200 {object} map[string]interface{} +// @Router /admin/fees/{id}/audit [get] +func (h *FeeAdminHandler) GetFeeAuditLog(c *gin.Context) { + id, err := parseIDParam(c, "id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fee config ID"}) + return + } + + page := queryIntDefault(c, "page", 1) + limit := queryIntDefault(c, "limit", 20) + + logs, total, err := h.feeService.GetAuditLog(id, page, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": logs, + "total": total, + "page": page, + "limit": limit, + }) +} + +// PreviewFee handles GET /api/v1/fees/preview +// @Summary Preview effective fee for a transaction +// @Description Returns the computed fee for a given transaction amount and user's 30-day volume +// @Tags fees +// @Param fee_type query string true "Fee type" +// @Param amount query int true "Transaction amount in stroops" +// @Param user_volume query int false "User's 30-day volume in stroops" +// @Success 200 {object} models.EffectiveFee +// @Router /fees/preview [get] +func (h *FeeAdminHandler) PreviewFee(c *gin.Context) { + feeTypeStr := c.Query("fee_type") + amountStr := c.Query("amount") + userVolumeStr := c.DefaultQuery("user_volume", "0") + + if feeTypeStr == "" || amountStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "fee_type and amount are required"}) + return + } + + amount, err := strconv.ParseInt(amountStr, 10, 64) + if err != nil || amount <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "amount must be a positive integer"}) + return + } + + userVolume, _ := strconv.ParseInt(userVolumeStr, 10, 64) + + result, err := h.feeService.CalculateFee(models.FeeType(feeTypeStr), amount, userVolume) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetActiveFee handles GET /api/v1/fees/active +// @Summary Get the active fee config for a fee type +// @Tags fees +// @Param fee_type query string true "Fee type" +// @Success 200 {object} models.FeeConfig +// @Router /fees/active [get] +func (h *FeeAdminHandler) GetActiveFee(c *gin.Context) { + feeTypeStr := c.Query("fee_type") + if feeTypeStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "fee_type is required"}) + return + } + + cfg, err := h.feeService.GetActiveFeeConfig(models.FeeType(feeTypeStr)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, cfg) +} + +// getRequestingAdminID extracts the authenticated user's ID from the gin context. +// The JWT middleware stores it as "user_id". +func getRequestingAdminID(c *gin.Context) uint { + if val, exists := c.Get("user_id"); exists { + if id, ok := val.(uint); ok { + return id + } + } + return 0 +} + +// parseIDParam parses a uint path parameter from the context. +func parseIDParam(c *gin.Context, key string) (uint, error) { + raw := c.Param(key) + val, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + return 0, err + } + return uint(val), nil +} + +// queryIntDefault returns query param as int or a default value. +func queryIntDefault(c *gin.Context, key string, def int) int { + raw := c.Query(key) + if raw == "" { + return def + } + val, err := strconv.Atoi(raw) + if err != nil || val < 1 { + return def + } + return val +} + +// resolveTime returns t if non-nil, otherwise the fallback. +func resolveTime(t *time.Time, fallback time.Time) time.Time { + if t != nil { + return *t + } + return fallback +} diff --git a/backend/handlers/fee_admin_test.go b/backend/handlers/fee_admin_test.go new file mode 100644 index 0000000..32423a1 --- /dev/null +++ b/backend/handlers/fee_admin_test.go @@ -0,0 +1,294 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yourusername/kor-assetforge/models" + "github.com/yourusername/kor-assetforge/services" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupFeeTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&mode=memory"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.FeeConfig{}, + &models.FeeAuditLog{}, + )) + return db +} + +func setupFeeRouter(db *gorm.DB, adminID uint) *gin.Engine { + gin.SetMode(gin.TestMode) + svc := services.NewFeeService(db) + h := NewFeeAdminHandler(db, svc) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", adminID) + c.Next() + }) + + admin := r.Group("/admin/fees") + { + admin.POST("", h.CreateFeeConfig) + admin.GET("", h.ListFeeConfigs) + admin.GET("/:id", h.GetFeeConfig) + admin.PUT("/:id", h.UpdateFeeConfig) + admin.DELETE("/:id", h.DeactivateFeeConfig) + admin.GET("/:id/audit", h.GetFeeAuditLog) + } + r.GET("/fees/preview", h.PreviewFee) + r.GET("/fees/active", h.GetActiveFee) + + return r +} + +func TestFeeAdminHandler_CreateAndList(t *testing.T) { + db := setupFeeTestDB(t) + r := setupFeeRouter(db, 1) + + body := map[string]interface{}{ + "name": "Standard Marketplace Fee", + "fee_type": "marketplace", + "base_basis_points": 30, + "min_basis_points": 5, + "max_basis_points": 100, + "volume_tiers": json.RawMessage(`[{"min_volume_stroops":1000000,"discount_bps":5}]`), + "reason": "initial setup", + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/admin/fees", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + + var created models.FeeConfig + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + assert.Equal(t, "Standard Marketplace Fee", created.Name) + assert.Equal(t, models.FeeTypeMarketplace, created.FeeType) + assert.Equal(t, 30, created.BaseBasisPoints) + assert.True(t, created.Active) + + // List + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/admin/fees?fee_type=marketplace", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var list map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &list)) + assert.Equal(t, float64(1), list["total"]) +} + +func TestFeeAdminHandler_UpdateFeeConfig(t *testing.T) { + db := setupFeeTestDB(t) + r := setupFeeRouter(db, 1) + + // Create + createBody := map[string]interface{}{ + "name": "Transfer Fee", + "fee_type": "transfer", + "base_basis_points": 20, + "min_basis_points": 2, + "max_basis_points": 50, + } + bodyBytes, _ := json.Marshal(createBody) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/admin/fees", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var created models.FeeConfig + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + + // Update + updateBody := map[string]interface{}{ + "name": "Transfer Fee", + "fee_type": "transfer", + "base_basis_points": 25, + "min_basis_points": 2, + "max_basis_points": 50, + "reason": "raised due to network costs", + } + updateBytes, _ := json.Marshal(updateBody) + w = httptest.NewRecorder() + req, _ = http.NewRequest("PUT", fmt.Sprintf("/admin/fees/%d", created.ID), bytes.NewBuffer(updateBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var updated models.FeeConfig + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) + assert.Equal(t, 25, updated.BaseBasisPoints) + + // Audit log should have 2 entries + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", fmt.Sprintf("/admin/fees/%d/audit", created.ID), nil) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var auditRes map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &auditRes)) + assert.Equal(t, float64(2), auditRes["total"]) +} + +func TestFeeAdminHandler_DeactivateFeeConfig(t *testing.T) { + db := setupFeeTestDB(t) + r := setupFeeRouter(db, 1) + + // Create + createBody := map[string]interface{}{ + "name": "Staking Fee", + "fee_type": "staking", + "base_basis_points": 15, + "min_basis_points": 1, + "max_basis_points": 30, + } + bodyBytes, _ := json.Marshal(createBody) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/admin/fees", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var created models.FeeConfig + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) + + // Deactivate + deactivateBody := map[string]interface{}{"reason": "deprecated"} + deactivateBytes, _ := json.Marshal(deactivateBody) + w = httptest.NewRecorder() + req, _ = http.NewRequest("DELETE", fmt.Sprintf("/admin/fees/%d", created.ID), bytes.NewBuffer(deactivateBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + // Verify it is deactivated + var cfg models.FeeConfig + db.First(&cfg, created.ID) + assert.False(t, cfg.Active) +} + +func TestFeeAdminHandler_PreviewFee(t *testing.T) { + db := setupFeeTestDB(t) + r := setupFeeRouter(db, 1) + + // Create a liquidity fee with a tier + effectiveFrom := time.Now().Add(-1 * time.Hour) + tiersJSON := `[{"min_volume_stroops":500000,"discount_bps":10}]` + svc := services.NewFeeService(db) + cfg := &models.FeeConfig{ + Name: "Liquidity Fee", + FeeType: models.FeeTypeLiquidity, + BaseBasisPoints: 50, + MinBasisPoints: 5, + MaxBasisPoints: 200, + VolumeTiers: tiersJSON, + Active: true, + EffectiveFrom: effectiveFrom, + } + require.NoError(t, svc.CreateFeeConfig(cfg, 1, "test")) + + t.Run("No tier - base fee applied", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/fees/preview?fee_type=liquidity&amount=10000&user_volume=100", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var result models.EffectiveFee + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, 50, result.AppliedBps) + assert.Nil(t, result.TierApplied) + // fee = 10000 * 50 / 10000 = 50 + assert.Equal(t, int64(50), result.FeeAmount) + assert.Equal(t, int64(9950), result.NetAmount) + }) + + t.Run("Tier discount applied", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/fees/preview?fee_type=liquidity&amount=10000&user_volume=600000", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var result models.EffectiveFee + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + // 50 - 10 = 40 bps + assert.Equal(t, 40, result.AppliedBps) + assert.Equal(t, 10, result.DiscountBps) + require.NotNil(t, result.TierApplied) + assert.Equal(t, int64(500000), result.TierApplied.MinVolumeStroops) + }) + + t.Run("Missing fee_type returns 400", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/fees/preview?amount=1000", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("Unknown fee_type returns 400", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/fees/preview?fee_type=unknown&amount=1000", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} + +func TestFeeAdminHandler_ValidationErrors(t *testing.T) { + db := setupFeeTestDB(t) + r := setupFeeRouter(db, 1) + + t.Run("Invalid fee_type rejected", func(t *testing.T) { + body := map[string]interface{}{ + "name": "Bad Fee", + "fee_type": "invalid_type", + "base_basis_points": 30, + "max_basis_points": 100, + } + bodyBytes, _ := json.Marshal(body) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/admin/fees", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("Max below base rejected", func(t *testing.T) { + body := map[string]interface{}{ + "name": "Bad Fee2", + "fee_type": "marketplace", + "base_basis_points": 100, + "max_basis_points": 50, + } + bodyBytes, _ := json.Marshal(body) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/admin/fees", bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("Get nonexistent config returns 404", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/admin/fees/99999", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} diff --git a/backend/migrations/sql/0013_create_fee_config.down.sql b/backend/migrations/sql/0013_create_fee_config.down.sql new file mode 100644 index 0000000..af610cf --- /dev/null +++ b/backend/migrations/sql/0013_create_fee_config.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS fee_audit_logs; +DROP TABLE IF EXISTS fee_configs; diff --git a/backend/migrations/sql/0013_create_fee_config.up.sql b/backend/migrations/sql/0013_create_fee_config.up.sql new file mode 100644 index 0000000..348fde8 --- /dev/null +++ b/backend/migrations/sql/0013_create_fee_config.up.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS fee_configs ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + fee_type VARCHAR(64) NOT NULL, + base_basis_points INT NOT NULL, + min_basis_points INT NOT NULL DEFAULT 0, + max_basis_points INT NOT NULL, + volume_tiers TEXT NOT NULL DEFAULT '[]', + active BOOLEAN NOT NULL DEFAULT true, + effective_from TIMESTAMPTZ NOT NULL, + effective_until TIMESTAMPTZ, + created_by_admin_id BIGINT NOT NULL, + updated_by_admin_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT fee_configs_name_key UNIQUE (name) +); + +CREATE INDEX IF NOT EXISTS idx_fee_configs_fee_type ON fee_configs (fee_type); +CREATE INDEX IF NOT EXISTS idx_fee_configs_active ON fee_configs (active); +CREATE INDEX IF NOT EXISTS idx_fee_configs_deleted_at ON fee_configs (deleted_at); + +CREATE TABLE IF NOT EXISTS fee_audit_logs ( + id BIGSERIAL PRIMARY KEY, + fee_config_id BIGINT NOT NULL REFERENCES fee_configs(id), + admin_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + previous_json TEXT, + new_json TEXT, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fee_audit_logs_fee_config_id ON fee_audit_logs (fee_config_id); +CREATE INDEX IF NOT EXISTS idx_fee_audit_logs_admin_id ON fee_audit_logs (admin_id); +CREATE INDEX IF NOT EXISTS idx_fee_audit_logs_created_at ON fee_audit_logs (created_at DESC); diff --git a/backend/models/fee_config.go b/backend/models/fee_config.go new file mode 100644 index 0000000..df2c44f --- /dev/null +++ b/backend/models/fee_config.go @@ -0,0 +1,71 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// FeeType distinguishes the transaction type that a fee rule applies to. +type FeeType string + +const ( + FeeTypeMarketplace FeeType = "marketplace" + FeeTypeTransfer FeeType = "transfer" + FeeTypeStaking FeeType = "staking" + FeeTypeLiquidity FeeType = "liquidity" +) + +// FeeConfig stores a dynamic, admin-configurable fee rule. A rule is active +// when Active is true. Multiple rules for the same FeeType can coexist to +// implement tiered pricing by volume threshold. +type FeeConfig struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null;uniqueIndex" json:"name"` + FeeType FeeType `gorm:"not null;index" json:"fee_type"` + BaseBasisPoints int `gorm:"not null" json:"base_basis_points"` // Default fee in bps (1 bps = 0.01%) + MinBasisPoints int `gorm:"not null;default:0" json:"min_basis_points"` // Floor after any discount + MaxBasisPoints int `gorm:"not null" json:"max_basis_points"` // Ceiling (sanity guard) + VolumeTiers string `gorm:"type:text;not null;default:'[]'" json:"volume_tiers"` // JSON []VolumeTier + Active bool `gorm:"not null;default:true;index" json:"active"` + EffectiveFrom time.Time `gorm:"not null" json:"effective_from"` + EffectiveUntil *time.Time `json:"effective_until,omitempty"` + CreatedByAdminID uint `gorm:"not null" json:"created_by_admin_id"` + UpdatedByAdminID uint `gorm:"default:0" json:"updated_by_admin_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// VolumeTier defines a discount that applies when a user's 30-day volume +// (in stroops) exceeds the threshold. +type VolumeTier struct { + MinVolumeStroops int64 `json:"min_volume_stroops"` + DiscountBps int `json:"discount_bps"` // Reduction from base, in basis points +} + +// FeeAuditLog records every change made to a FeeConfig for compliance. +type FeeAuditLog struct { + ID uint `gorm:"primaryKey" json:"id"` + FeeConfigID uint `gorm:"not null;index" json:"fee_config_id"` + AdminID uint `gorm:"not null;index" json:"admin_id"` + Action string `gorm:"not null" json:"action"` // "created", "updated", "deactivated" + PreviousJSON string `gorm:"type:text" json:"previous_json,omitempty"` + NewJSON string `gorm:"type:text" json:"new_json,omitempty"` + Reason string `gorm:"type:text" json:"reason,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// EffectiveFee is a transient, non-persisted result returned from the +// fee service to callers that need to know the computed fee for a +// given transaction amount and user volume. +type EffectiveFee struct { + FeeConfigID uint `json:"fee_config_id"` + FeeType FeeType `json:"fee_type"` + BaseBasisPoints int `json:"base_basis_points"` + AppliedBps int `json:"applied_bps"` + DiscountBps int `json:"discount_bps"` + FeeAmount int64 `json:"fee_amount"` // in stroops + NetAmount int64 `json:"net_amount"` // amount - fee, in stroops + TierApplied *VolumeTier `json:"tier_applied,omitempty"` +} diff --git a/backend/router/router.go b/backend/router/router.go index 9b0a16a..edf5cf7 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -155,6 +155,18 @@ func SetupRouter(db *gorm.DB) *gin.Engine { adminGroup.POST("/staking/distribute", func(c *gin.Context) { handlers.NewStakingHandler(db).DistributeRewards(c) }) + // Dynamic fee configuration admin endpoints (#177) + feeService := services.NewFeeService(db) + feeAdminHandler := handlers.NewFeeAdminHandler(db, feeService) + feeAdminGroup := adminGroup.Group("/fees") + { + feeAdminGroup.POST("", feeAdminHandler.CreateFeeConfig) + feeAdminGroup.GET("", feeAdminHandler.ListFeeConfigs) + feeAdminGroup.GET("/:id", feeAdminHandler.GetFeeConfig) + feeAdminGroup.PUT("/:id", feeAdminHandler.UpdateFeeConfig) + feeAdminGroup.DELETE("/:id", feeAdminHandler.DeactivateFeeConfig) + feeAdminGroup.GET("/:id/audit", feeAdminHandler.GetFeeAuditLog) + } } } @@ -297,19 +309,28 @@ func SetupRouter(db *gorm.DB) *gin.Engine { // Legal compliance routes legalHandler := handlers.NewLegalHandler(db) + gdprExportService := services.NewDataExportService(db, emailService) + gdprHandler := handlers.NewGDPRHandler(db, gdprExportService) + legalGroup := v1.Group("/legal") { legalGroup.GET("/:type", legalHandler.GetActiveDocument) legalGroup.GET("/:type/versions", legalHandler.ListDocumentVersions) + legalGroup.GET("/gdpr/export/download/:token", gdprHandler.DownloadExport) } legalProtected := protected.Group("/legal") { legalProtected.POST("/consent", legalHandler.RecordConsent) legalProtected.GET("/consent/history", legalHandler.GetConsentHistory) legalProtected.GET("/consent/pending", legalHandler.CheckPendingConsents) - legalProtected.POST("/gdpr/export", legalHandler.RequestDataExport) - legalProtected.GET("/gdpr/export/:id", legalHandler.GetDataExportStatus) + legalProtected.POST("/gdpr/export", gdprHandler.RequestDataExport) + legalProtected.GET("/gdpr/export/:id", gdprHandler.GetDataExportStatus) } + + // Public fee endpoints - preview and active config (#177) + feePublicHandler := handlers.NewFeeAdminHandler(db, services.NewFeeService(db)) + v1.GET("/fees/preview", feePublicHandler.PreviewFee) + v1.GET("/fees/active", feePublicHandler.GetActiveFee) } // API v2 routes diff --git a/backend/services/fee_service.go b/backend/services/fee_service.go new file mode 100644 index 0000000..0e708ee --- /dev/null +++ b/backend/services/fee_service.go @@ -0,0 +1,300 @@ +package services + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "time" + + "github.com/yourusername/kor-assetforge/models" + "gorm.io/gorm" +) + +// FeeService handles dynamic fee configuration retrieval and calculation. +type FeeService struct { + db *gorm.DB +} + +// NewFeeService creates a new FeeService. +func NewFeeService(db *gorm.DB) *FeeService { + return &FeeService{db: db} +} + +// CreateFeeConfig persists a new fee configuration and records the audit log. +func (s *FeeService) CreateFeeConfig(cfg *models.FeeConfig, adminID uint, reason string) error { + if err := s.validateConfig(cfg); err != nil { + return err + } + + cfg.CreatedByAdminID = adminID + cfg.UpdatedByAdminID = adminID + + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(cfg).Error; err != nil { + return fmt.Errorf("failed to create fee config: %w", err) + } + + newJSON, _ := json.Marshal(cfg) + log := &models.FeeAuditLog{ + FeeConfigID: cfg.ID, + AdminID: adminID, + Action: "created", + NewJSON: string(newJSON), + Reason: reason, + CreatedAt: time.Now(), + } + return tx.Create(log).Error + }) +} + +// UpdateFeeConfig updates an existing fee configuration and records a diff in +// the audit log. +func (s *FeeService) UpdateFeeConfig(id uint, updates *models.FeeConfig, adminID uint, reason string) (*models.FeeConfig, error) { + if err := s.validateConfig(updates); err != nil { + return nil, err + } + + var existing models.FeeConfig + if err := s.db.First(&existing, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("fee config not found") + } + return nil, fmt.Errorf("failed to load fee config: %w", err) + } + + prevJSON, _ := json.Marshal(existing) + + var txErr error + txErr = s.db.Transaction(func(tx *gorm.DB) error { + updates.ID = existing.ID + updates.CreatedByAdminID = existing.CreatedByAdminID + updates.UpdatedByAdminID = adminID + updates.CreatedAt = existing.CreatedAt + updates.UpdatedAt = time.Now() + + if err := tx.Save(updates).Error; err != nil { + return fmt.Errorf("failed to update fee config: %w", err) + } + + newJSON, _ := json.Marshal(updates) + log := &models.FeeAuditLog{ + FeeConfigID: id, + AdminID: adminID, + Action: "updated", + PreviousJSON: string(prevJSON), + NewJSON: string(newJSON), + Reason: reason, + CreatedAt: time.Now(), + } + return tx.Create(log).Error + }) + if txErr != nil { + return nil, txErr + } + return updates, nil +} + +// DeactivateFeeConfig marks a fee config as inactive without deleting it. +func (s *FeeService) DeactivateFeeConfig(id uint, adminID uint, reason string) error { + var cfg models.FeeConfig + if err := s.db.First(&cfg, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("fee config not found") + } + return fmt.Errorf("failed to load fee config: %w", err) + } + + prevJSON, _ := json.Marshal(cfg) + + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&cfg).Updates(map[string]interface{}{ + "active": false, + "updated_by_admin_id": adminID, + "updated_at": time.Now(), + }).Error; err != nil { + return fmt.Errorf("failed to deactivate fee config: %w", err) + } + + cfg.Active = false + newJSON, _ := json.Marshal(cfg) + log := &models.FeeAuditLog{ + FeeConfigID: id, + AdminID: adminID, + Action: "deactivated", + PreviousJSON: string(prevJSON), + NewJSON: string(newJSON), + Reason: reason, + CreatedAt: time.Now(), + } + return tx.Create(log).Error + }) +} + +// GetActiveFeeConfig retrieves the currently active fee config for a given type. +// When multiple active configs exist for the same type, the one with the most +// recent EffectiveFrom that is not in the future is returned. +func (s *FeeService) GetActiveFeeConfig(feeType models.FeeType) (*models.FeeConfig, error) { + var cfg models.FeeConfig + err := s.db. + Where("fee_type = ? AND active = true AND effective_from <= ?", feeType, time.Now()). + Where("effective_until IS NULL OR effective_until > ?", time.Now()). + Order("effective_from DESC"). + First(&cfg).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("no active fee config for type: %s", feeType) + } + return nil, fmt.Errorf("failed to query fee config: %w", err) + } + return &cfg, nil +} + +// ListFeeConfigs returns paginated fee configs optionally filtered by type. +func (s *FeeService) ListFeeConfigs(feeType string, page, limit int) ([]models.FeeConfig, int64, error) { + query := s.db.Model(&models.FeeConfig{}) + if feeType != "" { + query = query.Where("fee_type = ?", feeType) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count failed: %w", err) + } + + offset := (page - 1) * limit + var configs []models.FeeConfig + if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil { + return nil, 0, fmt.Errorf("list failed: %w", err) + } + + return configs, total, nil +} + +// CalculateFee computes the effective fee for a given transaction amount and +// the user's 30-day volume. Both amounts are expressed in stroops. +func (s *FeeService) CalculateFee(feeType models.FeeType, amountStroops int64, userVolume30dStroops int64) (*models.EffectiveFee, error) { + cfg, err := s.GetActiveFeeConfig(feeType) + if err != nil { + return nil, err + } + + tiers, err := parseVolumeTiers(cfg.VolumeTiers) + if err != nil { + return nil, fmt.Errorf("invalid volume tiers in config: %w", err) + } + + appliedBps, tierApplied := applyTierDiscount(cfg.BaseBasisPoints, cfg.MinBasisPoints, tiers, userVolume30dStroops) + + feeAmount := amountStroops * int64(appliedBps) / 10_000 + netAmount := amountStroops - feeAmount + + discountBps := cfg.BaseBasisPoints - appliedBps + + return &models.EffectiveFee{ + FeeConfigID: cfg.ID, + FeeType: cfg.FeeType, + BaseBasisPoints: cfg.BaseBasisPoints, + AppliedBps: appliedBps, + DiscountBps: discountBps, + FeeAmount: feeAmount, + NetAmount: netAmount, + TierApplied: tierApplied, + }, nil +} + +// GetAuditLog returns the audit trail for a specific fee config. +func (s *FeeService) GetAuditLog(feeConfigID uint, page, limit int) ([]models.FeeAuditLog, int64, error) { + query := s.db.Model(&models.FeeAuditLog{}).Where("fee_config_id = ?", feeConfigID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count failed: %w", err) + } + + offset := (page - 1) * limit + var logs []models.FeeAuditLog + if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil { + return nil, 0, fmt.Errorf("list failed: %w", err) + } + + return logs, total, nil +} + +// parseVolumeTiers deserialises the JSON column into a slice of VolumeTier. +func parseVolumeTiers(raw string) ([]models.VolumeTier, error) { + if raw == "" || raw == "[]" { + return nil, nil + } + var tiers []models.VolumeTier + if err := json.Unmarshal([]byte(raw), &tiers); err != nil { + return nil, err + } + return tiers, nil +} + +// applyTierDiscount selects the best matching tier for the user's volume and +// returns the discounted basis-points value and the tier that was applied. +func applyTierDiscount(baseBps, minBps int, tiers []models.VolumeTier, volumeStroops int64) (int, *models.VolumeTier) { + if len(tiers) == 0 { + return baseBps, nil + } + + // Sort descending by threshold so we pick the highest matching tier first. + sort.Slice(tiers, func(i, j int) bool { + return tiers[i].MinVolumeStroops > tiers[j].MinVolumeStroops + }) + + for i := range tiers { + t := &tiers[i] + if volumeStroops >= t.MinVolumeStroops { + applied := baseBps - t.DiscountBps + if applied < minBps { + applied = minBps + } + return applied, t + } + } + + return baseBps, nil +} + +// validateConfig performs basic sanity checks on a FeeConfig before save. +func (s *FeeService) validateConfig(cfg *models.FeeConfig) error { + validTypes := map[models.FeeType]bool{ + models.FeeTypeMarketplace: true, + models.FeeTypeTransfer: true, + models.FeeTypeStaking: true, + models.FeeTypeLiquidity: true, + } + if !validTypes[cfg.FeeType] { + return fmt.Errorf("invalid fee_type: %s", cfg.FeeType) + } + if cfg.BaseBasisPoints < 0 { + return errors.New("base_basis_points cannot be negative") + } + if cfg.MaxBasisPoints < cfg.BaseBasisPoints { + return errors.New("max_basis_points must be >= base_basis_points") + } + if cfg.MinBasisPoints < 0 { + return errors.New("min_basis_points cannot be negative") + } + if cfg.MinBasisPoints > cfg.BaseBasisPoints { + return errors.New("min_basis_points cannot exceed base_basis_points") + } + if cfg.Name == "" { + return errors.New("name is required") + } + if cfg.EffectiveFrom.IsZero() { + cfg.EffectiveFrom = time.Now() + } + + // Validate volume tiers JSON if provided. + if cfg.VolumeTiers != "" && cfg.VolumeTiers != "[]" { + if _, err := parseVolumeTiers(cfg.VolumeTiers); err != nil { + return fmt.Errorf("volume_tiers is not valid JSON: %w", err) + } + } + + return nil +}