Compare commits
110 Commits
SEND010
...
59c83e414a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c83e414a | ||
|
|
743f683d92 | ||
|
|
ea1c69f14a | ||
|
|
97ba7500dc | ||
| 19b7b69bd8 | |||
|
|
b157522fdb | ||
| 202582626a | |||
|
|
6a2efd3d22 | ||
| a6374d1136 | |||
|
|
7c864dc304 | ||
| 4aeb06fd31 | |||
|
|
d1786dc5d9 | ||
| f5bf8cf6d0 | |||
| 7daa4ab027 | |||
|
|
6f2309669b | ||
|
|
e4847cd137 | ||
| dbd06a4162 | |||
|
|
1ec6cd8386 | ||
| 6daf567baf | |||
| 23a57e543d | |||
|
|
8adfab94b5 | ||
|
|
db488a31e8 | ||
| 3836ff5ef3 | |||
| aef5c99a22 | |||
|
|
be7c965234 | ||
|
|
63448ab267 | ||
| 34a565d86d | |||
|
|
171d90b3f7 | ||
| 5191336a49 | |||
|
|
48f64a722d | ||
| bde453d106 | |||
|
|
3bb33b8895 | ||
| 8ee092089f | |||
|
|
eca3d0d62e | ||
| aba743406a | |||
|
|
deb29efde3 | ||
| 6995afc47d | |||
|
|
7b645a3bbe | ||
| 0ddd92b88b | |||
|
|
6151e3d3a5 | ||
| af7abbb095 | |||
|
|
71be1ef9f0 | ||
| 3df358d865 | |||
|
|
c6b2ba486b | ||
| d324e455cc | |||
|
|
8c87e5534e | ||
| bcb3e9e647 | |||
|
|
43f26143df | ||
| ed6e6bf1ba | |||
|
|
2d38b974ba | ||
|
|
610296b301 | ||
|
|
fcc68c8380 | ||
| b96babdfd4 | |||
| 69fdbf4e95 | |||
|
|
d32b2aa959 | ||
|
|
be10839e3a | ||
| d530af43a1 | |||
|
|
aa673fb26d | ||
| d978e24a9d | |||
|
|
31d93e5113 | ||
| f02f3449f3 | |||
|
|
d46822b9bb | ||
| 0505b2314e | |||
|
|
407e704352 | ||
| 4251dfb2c6 | |||
|
|
e0820c47c2 | ||
| 68b82cbca2 | |||
|
|
9e6d530385 | ||
| 5836292adb | |||
| 0c6229331f | |||
| 8cb6a64f2b | |||
|
|
4453dab366 | ||
|
|
512f25f74f | ||
|
|
43020f3eb6 | ||
| 964e90767d | |||
|
|
03cd2f4784 | ||
| 2d735aa7f5 | |||
|
|
342dd5328f | ||
| 915ed66b08 | |||
|
|
fe73b3078a | ||
| 76204822e7 | |||
|
|
77c205f9b2 | ||
| 6a29dc8907 | |||
|
|
8f1f279792 | ||
| 1f0b54d590 | |||
|
|
cefb9706f9 | ||
| 79b7899658 | |||
|
|
c941319c4e | ||
| e6626600cc | |||
|
|
e74c06e87a | ||
| c3647bfc46 | |||
|
|
3ff81038a9 | ||
| d6d9d47e67 | |||
|
|
034eb943e2 | ||
| 93bd0bf002 | |||
|
|
946bfa217c | ||
| 318255405b | |||
|
|
19d4ee1d33 | ||
| bc6a56c129 | |||
|
|
ec54579921 | ||
| 1ed76f7243 | |||
|
|
6527d183ec | ||
| 41b0dec460 | |||
|
|
d26ba84094 | ||
|
|
4073c8819c | ||
|
|
47ada0691c | ||
| 97c67670e5 | |||
|
|
dfad7fb335 | ||
| 41abf723e6 | |||
|
|
2d6586430f |
72
.woodpecker/discovery.yml
Normal file
72
.woodpecker/discovery.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- DISCOVERY_IMAGE_PATH: discovery/service
|
||||||
|
DISCOVERY_DOCKERFILE: ci/prod/compose/discovery.dockerfile
|
||||||
|
DISCOVERY_ENV: prod
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: version
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail 2>/dev/null || set -eu
|
||||||
|
- apk add --no-cache git
|
||||||
|
- GIT_REV="$(git rev-parse --short HEAD)"
|
||||||
|
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
- APP_V="$(cat version)"
|
||||||
|
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
|
||||||
|
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
|
||||||
|
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
|
||||||
|
|
||||||
|
- name: proto
|
||||||
|
image: golang:alpine
|
||||||
|
depends_on: [ version ]
|
||||||
|
commands:
|
||||||
|
- set -eu
|
||||||
|
- apk add --no-cache bash git build-base protoc protobuf-dev
|
||||||
|
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||||
|
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||||
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
|
- name: secrets
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ version ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||||
|
- mkdir -p secrets
|
||||||
|
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||||
|
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||||
|
- chmod 600 secrets/SSH_KEY
|
||||||
|
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||||
|
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||||
|
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||||
|
|
||||||
|
- name: build-image
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
depends_on: [ proto, secrets ]
|
||||||
|
commands:
|
||||||
|
- sh ci/scripts/discovery/build-image.sh
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ secrets, build-image ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
|
||||||
|
- mkdir -p /root/.ssh
|
||||||
|
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
|
||||||
|
- sh ci/scripts/discovery/deploy.sh
|
||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +49,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Imp struct {
|
|||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
oracleClient oracleclient.Client
|
oracleClient oracleclient.Client
|
||||||
|
service *fees.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -65,6 +66,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
|||||||
|
|
||||||
func (i *Imp) Shutdown() {
|
func (i *Imp) Shutdown() {
|
||||||
if i.app == nil {
|
if i.app == nil {
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
if i.oracleClient != nil {
|
if i.oracleClient != nil {
|
||||||
_ = i.oracleClient.Close()
|
_ = i.oracleClient.Close()
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,10 @@ func (i *Imp) Shutdown() {
|
|||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -121,7 +129,9 @@ func (i *Imp) Start() error {
|
|||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||||
}
|
}
|
||||||
return fees.NewService(logger, repo, producer, opts...), nil
|
svc := fees.NewService(logger, repo, producer, opts...)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/appversion"
|
||||||
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
@@ -15,9 +16,11 @@ import (
|
|||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
@@ -36,6 +39,7 @@ type Service struct {
|
|||||||
calculator Calculator
|
calculator Calculator
|
||||||
oracle oracleclient.Client
|
oracle oracleclient.Client
|
||||||
resolver FeeResolver
|
resolver FeeResolver
|
||||||
|
announcer *discovery.Announcer
|
||||||
feesv1.UnimplementedFeeEngineServer
|
feesv1.UnimplementedFeeEngineServer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +77,28 @@ func (s *Service) Register(router routers.GRPC) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "BILLING_FEES",
|
||||||
|
Operations: []string{"fee.calc"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||||
var (
|
var (
|
||||||
meta *feesv1.RequestMeta
|
meta *feesv1.RequestMeta
|
||||||
|
|||||||
3
api/discovery/.gitignore
vendored
Normal file
3
api/discovery/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
17
api/discovery/config.yml
Normal file
17
api/discovery/config.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9405"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Discovery Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
51
api/discovery/go.mod
Normal file
51
api/discovery/go.mod
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
module github.com/tech/sendico/discovery
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
225
api/discovery/go.sum
Normal file
225
api/discovery/go.sum
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
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/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||||
|
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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/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 v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
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-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
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/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||||
|
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
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/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
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.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
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-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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.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/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
27
api/discovery/internal/appversion/version.go
Normal file
27
api/discovery/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information. Populated at build-time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create() version.Printer {
|
||||||
|
vi := version.Info{
|
||||||
|
Program: "Sendico Discovery Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&vi)
|
||||||
|
}
|
||||||
47
api/discovery/internal/server/internal/config.go
Normal file
47
api/discovery/internal/server/internal/config.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultMetricsAddress = ":9405"
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
||||||
|
Messaging *msg.Config `yaml:"messaging"`
|
||||||
|
Metrics *metricsConfig `yaml:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsConfig struct {
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
|
data, err := os.ReadFile(i.file)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config{}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Runtime == nil {
|
||||||
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
|
||||||
|
cfg.Metrics.Address = defaultMetricsAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
58
api/discovery/internal/server/internal/discovery.go
Normal file
58
api/discovery/internal/server/internal/discovery.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/discovery/internal/appversion"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) startDiscovery(cfg *config) error {
|
||||||
|
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
||||||
|
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
|
||||||
|
}
|
||||||
|
|
||||||
|
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||||
|
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
|
||||||
|
|
||||||
|
registry := discovery.NewRegistry()
|
||||||
|
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc.Start()
|
||||||
|
i.registrySvc = svc
|
||||||
|
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "DISCOVERY",
|
||||||
|
InstanceID: discovery.InstanceID(),
|
||||||
|
Operations: []string{"discovery.lookup"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce)
|
||||||
|
i.announcer.Start()
|
||||||
|
|
||||||
|
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) stopDiscovery() {
|
||||||
|
if i == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.announcer != nil {
|
||||||
|
i.announcer.Stop()
|
||||||
|
i.announcer = nil
|
||||||
|
}
|
||||||
|
if i.registrySvc != nil {
|
||||||
|
i.registrySvc.Stop()
|
||||||
|
i.registrySvc = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
85
api/discovery/internal/server/internal/metrics.go
Normal file
85
api/discovery/internal/server/internal/metrics.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) startMetrics(cfg *metricsConfig) {
|
||||||
|
if i == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
address := ""
|
||||||
|
if cfg != nil {
|
||||||
|
address = strings.TrimSpace(cfg.Address)
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
i.logger.Info("Metrics endpoint disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
var healthRouter routers.Health
|
||||||
|
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
|
||||||
|
i.logger.Warn("Failed to initialise health router", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
hr.SetStatus(health.SSStarting)
|
||||||
|
healthRouter = hr
|
||||||
|
}
|
||||||
|
|
||||||
|
i.metricsHealth = healthRouter
|
||||||
|
i.metricsSrv = &http.Server{
|
||||||
|
Addr: address,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if healthRouter != nil {
|
||||||
|
healthRouter.SetStatus(health.SSRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||||
|
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||||
|
if healthRouter != nil {
|
||||||
|
healthRouter.SetStatus(health.SSTerminating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) shutdownMetrics(ctx context.Context) {
|
||||||
|
if i.metricsHealth != nil {
|
||||||
|
i.metricsHealth.SetStatus(health.SSTerminating)
|
||||||
|
i.metricsHealth.Finish()
|
||||||
|
i.metricsHealth = nil
|
||||||
|
}
|
||||||
|
if i.metricsSrv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
i.logger.Info("Metrics server stopped")
|
||||||
|
}
|
||||||
|
i.metricsSrv = nil
|
||||||
|
}
|
||||||
109
api/discovery/internal/server/internal/serverimp.go
Normal file
109
api/discovery/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
|
return &Imp{
|
||||||
|
logger: logger.Named("server"),
|
||||||
|
file: file,
|
||||||
|
debug: debug,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
i.initStopChannels()
|
||||||
|
defer i.closeDone()
|
||||||
|
|
||||||
|
i.logger.Info("Starting discovery service", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
||||||
|
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
messagingDriver := "none"
|
||||||
|
if cfg.Messaging != nil {
|
||||||
|
messagingDriver = string(cfg.Messaging.Driver)
|
||||||
|
}
|
||||||
|
metricsAddress := ""
|
||||||
|
if cfg.Metrics != nil {
|
||||||
|
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
||||||
|
}
|
||||||
|
if metricsAddress == "" {
|
||||||
|
metricsAddress = "disabled"
|
||||||
|
}
|
||||||
|
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
|
||||||
|
|
||||||
|
i.startMetrics(cfg.Metrics)
|
||||||
|
|
||||||
|
if err := i.startDiscovery(cfg); err != nil {
|
||||||
|
i.stopDiscovery()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
|
||||||
|
i.shutdownMetrics(ctx)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.logger.Info("Discovery service ready", zap.String("messaging_driver", messagingDriver))
|
||||||
|
|
||||||
|
<-i.stopCh
|
||||||
|
i.logger.Info("Discovery service stop signal received")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Shutdown() {
|
||||||
|
timeout := i.shutdownTimeout()
|
||||||
|
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
|
||||||
|
|
||||||
|
i.stopDiscovery()
|
||||||
|
i.signalStop()
|
||||||
|
|
||||||
|
if i.doneCh != nil {
|
||||||
|
<-i.doneCh
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.shutdownMetrics(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
i.logger.Info("Discovery service stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initStopChannels() {
|
||||||
|
if i.stopCh == nil {
|
||||||
|
i.stopCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
if i.doneCh == nil {
|
||||||
|
i.doneCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) signalStop() {
|
||||||
|
i.stopOnce.Do(func() {
|
||||||
|
if i.stopCh != nil {
|
||||||
|
close(i.stopCh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) closeDone() {
|
||||||
|
i.doneOnce.Do(func() {
|
||||||
|
if i.doneCh != nil {
|
||||||
|
close(i.doneCh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) shutdownTimeout() time.Duration {
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
return i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
return 15 * time.Second
|
||||||
|
}
|
||||||
28
api/discovery/internal/server/internal/types.go
Normal file
28
api/discovery/internal/server/internal/types.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *config
|
||||||
|
registrySvc *discovery.RegistryService
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
|
||||||
|
metricsSrv *http.Server
|
||||||
|
metricsHealth routers.Health
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
doneOnce sync.Once
|
||||||
|
stopCh chan struct{}
|
||||||
|
doneCh chan struct{}
|
||||||
|
}
|
||||||
11
api/discovery/internal/server/server.go
Normal file
11
api/discovery/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/discovery/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
17
api/discovery/main.go
Normal file
17
api/discovery/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/discovery/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/discovery/internal/server"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
smain "github.com/tech/sendico/pkg/server/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return si.Create(logger, file, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
smain.RunServer("main", appversion.Create(), factory)
|
||||||
|
}
|
||||||
@@ -49,6 +49,18 @@ metrics:
|
|||||||
enabled: true
|
enabled: true
|
||||||
address: ":9102"
|
address: ":9102"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: FX Ingestor
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
database:
|
database:
|
||||||
driver: mongodb
|
driver: mongodb
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import (
|
|||||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||||
"github.com/tech/sendico/pkg/api/routers/health"
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -68,6 +71,24 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var announcer *discovery.Announcer
|
||||||
|
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
|
||||||
|
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("Failed to initialize discovery broker", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "FX_INGESTOR",
|
||||||
|
Operations: []string{"fx.ingest"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
|
||||||
|
announcer.Start()
|
||||||
|
defer announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
|
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
|
||||||
metricsSrv.SetStatus(health.SSRunning)
|
metricsSrv.SetStatus(health.SSRunning)
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import (
|
|||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/messaging"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPollInterval = 30 * time.Second
|
const defaultPollInterval = 30 * time.Second
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||||
Market MarketConfig `yaml:"market"`
|
Market MarketConfig `yaml:"market"`
|
||||||
Database *db.Config `yaml:"database"`
|
Database *db.Config `yaml:"database"`
|
||||||
Metrics *MetricsConfig `yaml:"metrics"`
|
Metrics *MetricsConfig `yaml:"metrics"`
|
||||||
|
Messaging *messaging.Config `yaml:"messaging"`
|
||||||
|
|
||||||
pairs []Pair
|
pairs []Pair
|
||||||
pairsBySource map[mmodel.Driver][]PairConfig
|
pairsBySource map[mmodel.Driver][]PairConfig
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/tech/sendico/fx/ingestor/internal/app"
|
"github.com/tech/sendico/fx/ingestor/internal/app"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
|
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
lf "github.com/tech/sendico/pkg/mlogger/factory"
|
lf "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,7 @@ func main() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
|
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
|
||||||
|
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
|
||||||
defer logger.Sync()
|
defer logger.Sync()
|
||||||
|
|
||||||
av := appversion.Create()
|
av := appversion.Create()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -50,5 +50,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ type Imp struct {
|
|||||||
file string
|
file string
|
||||||
debug bool
|
debug bool
|
||||||
|
|
||||||
config *grpcapp.Config
|
config *grpcapp.Config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
service *oracle.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
@@ -38,6 +39,9 @@ func (i *Imp) Shutdown() {
|
|||||||
if i.app == nil {
|
if i.app == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
timeout := 15 * time.Second
|
timeout := 15 * time.Second
|
||||||
if i.config != nil && i.config.Runtime != nil {
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
@@ -59,10 +63,12 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
return oracle.NewService(logger, repo, producer), nil
|
svc := oracle.NewService(logger, repo, producer)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "fx", cfg, i.debug, repoFactory, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/oracle/internal/appversion"
|
||||||
"github.com/tech/sendico/fx/storage"
|
"github.com/tech/sendico/fx/storage"
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -36,19 +38,22 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
producer pmessaging.Producer
|
producer pmessaging.Producer
|
||||||
|
announcer *discovery.Announcer
|
||||||
oraclev1.UnimplementedOracleServer
|
oraclev1.UnimplementedOracleServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
||||||
initMetrics()
|
initMetrics()
|
||||||
return &Service{
|
svc := &Service{
|
||||||
logger: logger.Named("oracle"),
|
logger: logger.Named("oracle"),
|
||||||
storage: repo,
|
storage: repo,
|
||||||
producer: prod,
|
producer: prod,
|
||||||
}
|
}
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Register(router routers.GRPC) error {
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
@@ -57,6 +62,28 @@ func (s *Service) Register(router routers.GRPC) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "FX_ORACLE",
|
||||||
|
Operations: []string{"fx.quote"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FXOracle), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
responder := s.getQuoteResponder(ctx, req)
|
responder := s.getQuoteResponder(ctx, req)
|
||||||
@@ -130,7 +157,7 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
logger.Warn("Pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
||||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||||
default:
|
default:
|
||||||
logger.Warn("GetQuote failed to load pair", zap.Error(err))
|
logger.Warn("GetQuote failed to load pair", zap.Error(err))
|
||||||
@@ -150,7 +177,7 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
logger.Warn("Rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||||
default:
|
default:
|
||||||
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||||
|
|||||||
@@ -50,28 +50,28 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := s.Ping(ctx); err != nil {
|
if err := s.Ping(ctx); err != nil {
|
||||||
s.logger.Error("mongo ping failed during store init", zap.Error(err))
|
s.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ratesStore, err := store.NewRates(s.logger, db)
|
ratesStore, err := store.NewRates(s.logger, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to initialize rates store", zap.Error(err))
|
s.logger.Error("Failed to initialize rates store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
|
quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to initialize quotes store", zap.Error(err))
|
s.logger.Error("Failed to initialize quotes store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pairsStore, err := store.NewPair(s.logger, db)
|
pairsStore, err := store.NewPair(s.logger, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to initialize pair store", zap.Error(err))
|
s.logger.Error("Failed to initialize pair store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
currencyStore, err := store.NewCurrency(s.logger, db)
|
currencyStore, err := store.NewCurrency(s.logger, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to initialize currency store", zap.Error(err))
|
s.logger.Error("Failed to initialize currency store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
s.pairs = pairsStore
|
s.pairs = pairsStore
|
||||||
s.currencies = currencyStore
|
s.currencies = currencyStore
|
||||||
|
|
||||||
s.logger.Info("mongo storage ready")
|
s.logger.Info("Mongo storage ready")
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
|
|||||||
Unique: true,
|
Unique: true,
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(index); err != nil {
|
if err := repo.CreateIndex(index); err != nil {
|
||||||
logger.Error("failed to ensure currencies index", zap.Error(err))
|
logger.Error("Failed to ensure currencies index", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
childLogger := logger.Named(model.CurrenciesCollection)
|
childLogger := logger.Named(model.CurrenciesCollection)
|
||||||
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
childLogger.Debug("Currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
||||||
|
|
||||||
return ¤cyStore{
|
return ¤cyStore{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -43,17 +43,17 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
|
|||||||
|
|
||||||
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
|
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||||
if code == "" {
|
if code == "" {
|
||||||
c.logger.Warn("attempt to fetch currency with empty code")
|
c.logger.Warn("Attempt to fetch currency with empty code")
|
||||||
return nil, merrors.InvalidArgument("currencyStore: empty code")
|
return nil, merrors.InvalidArgument("currencyStore: empty code")
|
||||||
}
|
}
|
||||||
result := &model.Currency{}
|
result := &model.Currency{}
|
||||||
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
|
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.logger.Debug("currency not found", zap.String("code", code))
|
c.logger.Debug("Currency not found", zap.String("code", code))
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.logger.Debug("currency loaded", zap.String("code", code))
|
c.logger.Debug("Currency loaded", zap.String("code", code))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,20 +77,20 @@ func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Cur
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("failed to list currencies", zap.Error(err))
|
c.logger.Warn("Failed to list currencies", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.logger.Debug("listed currencies", zap.Int("count", len(currencies)))
|
c.logger.Debug("Listed currencies", zap.Int("count", len(currencies)))
|
||||||
return currencies, nil
|
return currencies, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
|
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
|
||||||
if currency == nil {
|
if currency == nil {
|
||||||
c.logger.Warn("attempt to upsert nil currency")
|
c.logger.Warn("Attempt to upsert nil currency")
|
||||||
return merrors.InvalidArgument("currencyStore: nil currency")
|
return merrors.InvalidArgument("currencyStore: nil currency")
|
||||||
}
|
}
|
||||||
if currency.Code == "" {
|
if currency.Code == "" {
|
||||||
c.logger.Warn("attempt to upsert currency with empty code")
|
c.logger.Warn("Attempt to upsert currency with empty code")
|
||||||
return merrors.InvalidArgument("currencyStore: empty code")
|
return merrors.InvalidArgument("currencyStore: empty code")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,16 +98,16 @@ func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) er
|
|||||||
filter := repository.Filter("code", currency.Code)
|
filter := repository.Filter("code", currency.Code)
|
||||||
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
|
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.logger.Debug("inserting new currency", zap.String("code", currency.Code))
|
c.logger.Debug("Inserting new currency", zap.String("code", currency.Code))
|
||||||
return c.repo.Insert(ctx, currency, filter)
|
return c.repo.Insert(ctx, currency, filter)
|
||||||
}
|
}
|
||||||
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
c.logger.Warn("Failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.GetID() != nil {
|
if existing.GetID() != nil {
|
||||||
currency.SetID(*existing.GetID())
|
currency.SetID(*existing.GetID())
|
||||||
}
|
}
|
||||||
c.logger.Debug("updating currency", zap.String("code", currency.Code))
|
c.logger.Debug("Updating currency", zap.String("code", currency.Code))
|
||||||
return c.repo.Update(ctx, currency)
|
return c.repo.Update(ctx, currency)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, erro
|
|||||||
Unique: true,
|
Unique: true,
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(index); err != nil {
|
if err := repo.CreateIndex(index); err != nil {
|
||||||
logger.Error("failed to ensure pairs index", zap.Error(err))
|
logger.Error("Failed to ensure pairs index", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection))
|
logger.Debug("Pair store initialised", zap.String("collection", model.PairsCollection))
|
||||||
|
|
||||||
return &pairStore{
|
return &pairStore{
|
||||||
logger: logger.Named(model.PairsCollection),
|
logger: logger.Named(model.PairsCollection),
|
||||||
@@ -53,16 +53,16 @@ func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.Error("failed to list enabled pairs", zap.Error(err))
|
p.logger.Warn("Failed to list enabled pairs", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs)))
|
p.logger.Debug("Listed enabled pairs", zap.Int("count", len(pairs)))
|
||||||
return pairs, nil
|
return pairs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
if pair.Base == "" || pair.Quote == "" {
|
if pair.Base == "" || pair.Quote == "" {
|
||||||
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
p.logger.Warn("Attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||||
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
|
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
|
||||||
}
|
}
|
||||||
result := &model.Pair{}
|
result := &model.Pair{}
|
||||||
@@ -71,21 +71,21 @@ func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pa
|
|||||||
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
|
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
|
||||||
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
|
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
p.logger.Debug("Pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
p.logger.Debug("Pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
||||||
if pair == nil {
|
if pair == nil {
|
||||||
p.logger.Warn("attempt to upsert nil pair")
|
p.logger.Warn("Attempt to upsert nil pair")
|
||||||
return merrors.InvalidArgument("pairStore: nil pair")
|
return merrors.InvalidArgument("pairStore: nil pair")
|
||||||
}
|
}
|
||||||
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
|
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
|
||||||
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
p.logger.Warn("Attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||||
return merrors.InvalidArgument("pairStore: incomplete pair")
|
return merrors.InvalidArgument("pairStore: incomplete pair")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,16 +96,16 @@ func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
|||||||
err := p.repo.FindOneByFilter(ctx, query, existing)
|
err := p.repo.FindOneByFilter(ctx, query, existing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
p.logger.Debug("Inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||||
return p.repo.Insert(ctx, pair, query)
|
return p.repo.Insert(ctx, pair, query)
|
||||||
}
|
}
|
||||||
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
p.logger.Warn("Failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.GetID() != nil {
|
if existing.GetID() != nil {
|
||||||
pair.SetID(*existing.GetID())
|
pair.SetID(*existing.GetID())
|
||||||
}
|
}
|
||||||
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
p.logger.Debug("Updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||||
return p.repo.Update(ctx, pair)
|
return p.repo.Update(ctx, pair)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
|||||||
|
|
||||||
for _, def := range indexes {
|
for _, def := range indexes {
|
||||||
if err := repo.CreateIndex(def); err != nil {
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
logger.Error("failed to ensure quotes index", zap.Error(err))
|
logger.Error("Failed to ensure quotes index", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
childLogger := logger.Named(model.QuotesCollection)
|
childLogger := logger.Named(model.QuotesCollection)
|
||||||
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection))
|
childLogger.Debug("Quotes store initialised", zap.String("collection", model.QuotesCollection))
|
||||||
|
|
||||||
return "esStore{
|
return "esStore{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -72,11 +72,11 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
|||||||
|
|
||||||
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
||||||
if quote == nil {
|
if quote == nil {
|
||||||
q.logger.Warn("attempt to issue nil quote")
|
q.logger.Warn("Attempt to issue nil quote")
|
||||||
return merrors.InvalidArgument("quotesStore: nil quote")
|
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||||
}
|
}
|
||||||
if quote.QuoteRef == "" {
|
if quote.QuoteRef == "" {
|
||||||
q.logger.Warn("attempt to issue quote with empty ref")
|
q.logger.Warn("Attempt to issue quote with empty ref")
|
||||||
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,32 +89,32 @@ func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
|||||||
quote.ConsumedByLedgerTxnRef = ""
|
quote.ConsumedByLedgerTxnRef = ""
|
||||||
quote.ConsumedAtUnixMs = nil
|
quote.ConsumedAtUnixMs = nil
|
||||||
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
|
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
|
||||||
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
q.logger.Warn("Failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
q.logger.Debug("Quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
|
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
|
||||||
if quoteRef == "" {
|
if quoteRef == "" {
|
||||||
q.logger.Warn("attempt to fetch quote with empty ref")
|
q.logger.Warn("Attempt to fetch quote with empty ref")
|
||||||
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||||
}
|
}
|
||||||
quote := &model.Quote{}
|
quote := &model.Quote{}
|
||||||
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
|
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef))
|
q.logger.Debug("Quote not found", zap.String("quote_ref", quoteRef))
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
q.logger.Debug("Quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
||||||
return quote, nil
|
return quote, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
|
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
|
||||||
if quoteRef == "" || ledgerTxnRef == "" {
|
if quoteRef == "" || ledgerTxnRef == "" {
|
||||||
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
q.logger.Warn("Attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||||
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
|
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
|||||||
when = time.Now()
|
when = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
q.logger.Debug("Consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||||
txn := q.txFactory.CreateTransaction()
|
txn := q.txFactory.CreateTransaction()
|
||||||
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
|
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||||
quote := &model.Quote{}
|
quote := &model.Quote{}
|
||||||
@@ -131,7 +131,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !quote.Firm {
|
if !quote.Firm {
|
||||||
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef))
|
q.logger.Warn("Quote not firm", zap.String("quote_ref", quoteRef))
|
||||||
return nil, storage.ErrQuoteNotFirm
|
return nil, storage.ErrQuoteNotFirm
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,16 +140,16 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
|||||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef))
|
q.logger.Info("Quote expired during consume", zap.String("quote_ref", quoteRef))
|
||||||
return nil, storage.ErrQuoteExpired
|
return nil, storage.ErrQuoteExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
if quote.Status == model.QuoteStatusConsumed {
|
if quote.Status == model.QuoteStatusConsumed {
|
||||||
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
|
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
|
||||||
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
q.logger.Debug("Quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||||
return quote, nil
|
return quote, nil
|
||||||
}
|
}
|
||||||
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
q.logger.Warn("Quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
||||||
return nil, storage.ErrQuoteConsumed
|
return nil, storage.ErrQuoteConsumed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,11 +157,11 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
|||||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
q.logger.Info("Quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||||
return quote, nil
|
return quote, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
q.logger.Warn("Quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
quote, _ := result.(*model.Quote)
|
quote, _ := result.(*model.Quote)
|
||||||
@@ -173,7 +173,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
|||||||
|
|
||||||
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||||
if cutoff.IsZero() {
|
if cutoff.IsZero() {
|
||||||
q.logger.Warn("attempt to expire quotes with zero cutoff")
|
q.logger.Warn("Attempt to expire quotes with zero cutoff")
|
||||||
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
|
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +188,11 @@ func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time)
|
|||||||
|
|
||||||
updated, err := q.repo.PatchMany(ctx, filter, patch)
|
updated, err := q.repo.PatchMany(ctx, filter, patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.logger.Error("failed to expire quotes", zap.Error(err))
|
q.logger.Warn("Failed to expire quotes", zap.Error(err))
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if updated > 0 {
|
if updated > 0 {
|
||||||
q.logger.Info("quotes expired", zap.Int("count", updated))
|
q.logger.Info("Quotes expired", zap.Int("count", updated))
|
||||||
}
|
}
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
|
|||||||
|
|
||||||
for _, def := range indexes {
|
for _, def := range indexes {
|
||||||
if err := repo.CreateIndex(def); err != nil {
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
logger.Error("failed to ensure rates index", zap.Error(err))
|
logger.Error("Failed to ensure rates index", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection))
|
logger.Debug("Rates store initialised", zap.String("collection", model.RatesCollection))
|
||||||
return &ratesStore{
|
return &ratesStore{
|
||||||
logger: logger.Named(model.RatesCollection),
|
logger: logger.Named(model.RatesCollection),
|
||||||
repo: repo,
|
repo: repo,
|
||||||
@@ -64,11 +64,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
|
|||||||
|
|
||||||
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
r.logger.Warn("attempt to upsert nil snapshot")
|
r.logger.Warn("Attempt to upsert nil snapshot")
|
||||||
return merrors.InvalidArgument("ratesStore: nil snapshot")
|
return merrors.InvalidArgument("ratesStore: nil snapshot")
|
||||||
}
|
}
|
||||||
if snapshot.RateRef == "" {
|
if snapshot.RateRef == "" {
|
||||||
r.logger.Warn("attempt to upsert snapshot with empty rate_ref")
|
r.logger.Warn("Attempt to upsert snapshot with empty rate_ref")
|
||||||
return merrors.InvalidArgument("ratesStore: empty rateRef")
|
return merrors.InvalidArgument("ratesStore: empty rateRef")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +82,17 @@ func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSna
|
|||||||
err := r.repo.FindOneByFilter(ctx, filter, existing)
|
err := r.repo.FindOneByFilter(ctx, filter, existing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
r.logger.Debug("Inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||||
return r.repo.Insert(ctx, snapshot, filter)
|
return r.repo.Insert(ctx, snapshot, filter)
|
||||||
}
|
}
|
||||||
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
r.logger.Warn("Failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing.GetID() != nil {
|
if existing.GetID() != nil {
|
||||||
snapshot.SetID(*existing.GetID())
|
snapshot.SetID(*existing.GetID())
|
||||||
}
|
}
|
||||||
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
r.logger.Debug("Updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||||
return r.repo.Update(ctx, snapshot)
|
return r.repo.Update(ctx, snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
@@ -24,6 +25,8 @@ type Client interface {
|
|||||||
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@ type grpcGatewayClient interface {
|
|||||||
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainGatewayClient struct {
|
type chainGatewayClient struct {
|
||||||
@@ -71,7 +76,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
return &chainGatewayClient{
|
return &chainGatewayClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
client: chainv1.NewChainGatewayServiceClient(conn),
|
client: unifiedv1.NewUnifiedGatewayServiceClient(conn),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +144,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain
|
|||||||
return c.client.EstimateTransferFee(ctx, req)
|
return c.client.EstimateTransferFee(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.ComputeGasTopUp(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.EnsureGasTopUp(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
timeout := c.cfg.CallTimeout
|
timeout := c.cfg.CallTimeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type Fake struct {
|
|||||||
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
CloseFn func() error
|
CloseFn func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
|
|||||||
return &chainv1.EstimateTransferFeeResponse{}, nil
|
return &chainv1.EstimateTransferFeeResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
if f.ComputeGasTopUpFn != nil {
|
||||||
|
return f.ComputeGasTopUpFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &chainv1.ComputeGasTopUpResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
if f.EnsureGasTopUpFn != nil {
|
||||||
|
return f.EnsureGasTopUpFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) Close() error {
|
func (f *Fake) Close() error {
|
||||||
if f.CloseFn != nil {
|
if f.CloseFn != nil {
|
||||||
return f.CloseFn()
|
return f.CloseFn()
|
||||||
|
|||||||
258
api/gateway/chain/client/rail_gateway.go
Normal file
258
api/gateway/chain/client/rail_gateway.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/payments/rail"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RailGatewayConfig defines metadata for the rail gateway adapter.
|
||||||
|
type RailGatewayConfig struct {
|
||||||
|
Rail string
|
||||||
|
Network string
|
||||||
|
Capabilities rail.RailCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
type chainRailGateway struct {
|
||||||
|
client Client
|
||||||
|
rail string
|
||||||
|
network string
|
||||||
|
capabilities rail.RailCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRailGateway wraps a chain gateway client into a rail gateway adapter.
|
||||||
|
func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway {
|
||||||
|
railName := strings.ToUpper(strings.TrimSpace(cfg.Rail))
|
||||||
|
if railName == "" {
|
||||||
|
railName = "CRYPTO"
|
||||||
|
}
|
||||||
|
return &chainRailGateway{
|
||||||
|
client: client,
|
||||||
|
rail: railName,
|
||||||
|
network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
|
||||||
|
capabilities: cfg.Capabilities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Rail() string {
|
||||||
|
return g.rail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Network() string {
|
||||||
|
return g.network
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Capabilities() rail.RailCapabilities {
|
||||||
|
return g.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
|
||||||
|
if g.client == nil {
|
||||||
|
return rail.RailResult{}, merrors.Internal("chain gateway: client is required")
|
||||||
|
}
|
||||||
|
orgRef := strings.TrimSpace(req.OrganizationRef)
|
||||||
|
if orgRef == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required")
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(req.FromAccountID)
|
||||||
|
if source == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required")
|
||||||
|
}
|
||||||
|
destRef := strings.TrimSpace(req.ToAccountID)
|
||||||
|
if destRef == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required")
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(req.Currency)
|
||||||
|
amountValue := strings.TrimSpace(req.Amount)
|
||||||
|
if currency == "" || amountValue == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required")
|
||||||
|
}
|
||||||
|
reqNetwork := strings.TrimSpace(req.Network)
|
||||||
|
if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.IdempotencyKey) == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo))
|
||||||
|
if err != nil {
|
||||||
|
return rail.RailResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fees := toServiceFees(req.Fees)
|
||||||
|
if len(fees) == 0 && req.Fee != nil {
|
||||||
|
if amt := moneyFromRail(req.Fee); amt != nil {
|
||||||
|
fees = []*chainv1.ServiceFeeBreakdown{{
|
||||||
|
FeeCode: "fee",
|
||||||
|
Amount: amt,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
SourceWalletRef: source,
|
||||||
|
Destination: dest,
|
||||||
|
Amount: &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amountValue,
|
||||||
|
},
|
||||||
|
Fees: fees,
|
||||||
|
Metadata: cloneMetadata(req.Metadata),
|
||||||
|
ClientReference: strings.TrimSpace(req.ClientReference),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return rail.RailResult{}, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetTransfer() == nil {
|
||||||
|
return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response")
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := resp.GetTransfer()
|
||||||
|
return rail.RailResult{
|
||||||
|
ReferenceID: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
Status: statusFromTransfer(transfer.GetStatus()),
|
||||||
|
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) {
|
||||||
|
if g.client == nil {
|
||||||
|
return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required")
|
||||||
|
}
|
||||||
|
ref := strings.TrimSpace(referenceID)
|
||||||
|
if ref == "" {
|
||||||
|
return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required")
|
||||||
|
}
|
||||||
|
resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref})
|
||||||
|
if err != nil {
|
||||||
|
return rail.ObserveResult{}, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetTransfer() == nil {
|
||||||
|
return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response")
|
||||||
|
}
|
||||||
|
transfer := resp.GetTransfer()
|
||||||
|
return rail.ObserveResult{
|
||||||
|
ReferenceID: ref,
|
||||||
|
Status: statusFromTransfer(transfer.GetStatus()),
|
||||||
|
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) {
|
||||||
|
managed, err := g.isManagedWallet(ctx, destRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if managed {
|
||||||
|
return &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef},
|
||||||
|
Memo: memo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) {
|
||||||
|
resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
|
||||||
|
if err != nil {
|
||||||
|
if status.Code(err) == codes.NotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetWallet() == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFromTransfer(status chainv1.TransferStatus) string {
|
||||||
|
switch status {
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||||
|
return rail.TransferStatusSuccess
|
||||||
|
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||||
|
return rail.TransferStatusFailed
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||||
|
return rail.TransferStatusRejected
|
||||||
|
case chainv1.TransferStatus_TRANSFER_SIGNING,
|
||||||
|
chainv1.TransferStatus_TRANSFER_PENDING,
|
||||||
|
chainv1.TransferStatus_TRANSFER_SUBMITTED:
|
||||||
|
return rail.TransferStatusPending
|
||||||
|
default:
|
||||||
|
return rail.TransferStatusPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown {
|
||||||
|
if len(fees) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees))
|
||||||
|
for _, fee := range fees {
|
||||||
|
amount := moneyFromRail(fee.Amount)
|
||||||
|
if amount == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, &chainv1.ServiceFeeBreakdown{
|
||||||
|
FeeCode: strings.TrimSpace(fee.FeeCode),
|
||||||
|
Amount: amount,
|
||||||
|
Description: strings.TrimSpace(fee.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyFromRail(m *rail.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
if currency == "" || amount == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func railMoneyFromProto(m *moneyv1.Money) *rail.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
if currency == "" || amount == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &rail.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMetadata(input map[string]string) map[string]string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(input))
|
||||||
|
for key, value := range input {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ grpc:
|
|||||||
enable_health: true
|
enable_health: true
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
address: ":9403"
|
address: ":9406"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
driver: mongodb
|
driver: mongodb
|
||||||
@@ -34,16 +34,23 @@ messaging:
|
|||||||
reconnect_wait: 5
|
reconnect_wait: 5
|
||||||
|
|
||||||
chains:
|
chains:
|
||||||
- name: arbitrum_one
|
- name: tron_mainnet
|
||||||
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
chain_id: 728126428 # 0x2b6653dc
|
||||||
|
native_token: TRX
|
||||||
|
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
||||||
|
gas_topup_policy:
|
||||||
|
buffer_percent: 0.10
|
||||||
|
min_native_balance_trx: 10
|
||||||
|
rounding_unit_trx: 1
|
||||||
|
max_topup_trx: 100
|
||||||
tokens:
|
tokens:
|
||||||
- symbol: USDC
|
|
||||||
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
|
|
||||||
- symbol: USDT
|
- symbol: USDT
|
||||||
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
|
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
- symbol: USDC
|
||||||
|
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
|
||||||
|
|
||||||
service_wallet:
|
service_wallet:
|
||||||
chain: arbitrum_one
|
chain: tron_mainnet
|
||||||
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
||||||
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
||||||
|
|
||||||
@@ -58,3 +65,4 @@ key_management:
|
|||||||
|
|
||||||
cache:
|
cache:
|
||||||
wallet_balance_ttl_seconds: 120
|
wallet_balance_ttl_seconds: 120
|
||||||
|
rpc_request_timeout_seconds: 15
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
@@ -86,5 +86,5 @@ require (
|
|||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
|||||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358 h1:B6uGMdZ4maUTJm+LYgBwEIDuJxgOUACw8K0Yg6jpNbY=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -362,10 +362,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -45,22 +45,22 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
|||||||
}
|
}
|
||||||
address := strings.TrimSpace(cfg.Address)
|
address := strings.TrimSpace(cfg.Address)
|
||||||
if address == "" {
|
if address == "" {
|
||||||
logger.Error("vault address missing")
|
logger.Error("Vault address missing")
|
||||||
return nil, merrors.InvalidArgument("vault key manager: address is required")
|
return nil, merrors.InvalidArgument("vault key manager: address is required")
|
||||||
}
|
}
|
||||||
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
|
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
|
||||||
if tokenEnv == "" {
|
if tokenEnv == "" {
|
||||||
logger.Error("vault token env missing")
|
logger.Error("Vault token env missing")
|
||||||
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
|
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
|
||||||
}
|
}
|
||||||
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
logger.Error("vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||||
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||||
}
|
}
|
||||||
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
||||||
if mountPath == "" {
|
if mountPath == "" {
|
||||||
logger.Error("vault mount path missing")
|
logger.Error("Vault mount path missing")
|
||||||
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
||||||
}
|
}
|
||||||
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||||
@@ -73,7 +73,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
|||||||
|
|
||||||
client, err := api.NewClient(clientCfg)
|
client, err := api.NewClient(clientCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create vault client", zap.Error(err))
|
logger.Error("Failed to create vault client", zap.Error(err))
|
||||||
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
||||||
}
|
}
|
||||||
client.SetToken(token)
|
client.SetToken(token)
|
||||||
@@ -94,17 +94,17 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
|||||||
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
|
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
|
||||||
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
|
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
|
||||||
if strings.TrimSpace(walletRef) == "" {
|
if strings.TrimSpace(walletRef) == "" {
|
||||||
m.logger.Warn("walletRef missing for managed key creation", zap.String("network", network))
|
m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
|
||||||
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
|
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(network) == "" {
|
if strings.TrimSpace(network) == "" {
|
||||||
m.logger.Warn("network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||||
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
|
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||||
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
|
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
|
||||||
}
|
}
|
||||||
privateKeyBytes := crypto.FromECDSA(privateKey)
|
privateKeyBytes := crypto.FromECDSA(privateKey)
|
||||||
@@ -115,7 +115,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
|
|||||||
|
|
||||||
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
|
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||||
zeroBytes(privateKeyBytes)
|
zeroBytes(privateKeyBytes)
|
||||||
zeroBytes(publicKeyBytes)
|
zeroBytes(publicKeyBytes)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,7 +123,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
|
|||||||
zeroBytes(privateKeyBytes)
|
zeroBytes(privateKeyBytes)
|
||||||
zeroBytes(publicKeyBytes)
|
zeroBytes(publicKeyBytes)
|
||||||
|
|
||||||
m.logger.Info("managed wallet key created",
|
m.logger.Info("Managed wallet key created",
|
||||||
zap.String("wallet_ref", walletRef),
|
zap.String("wallet_ref", walletRef),
|
||||||
zap.String("network", network),
|
zap.String("network", network),
|
||||||
zap.String("address", strings.ToLower(address)),
|
zap.String("address", strings.ToLower(address)),
|
||||||
@@ -158,43 +158,43 @@ func (m *Manager) buildKeyID(network, walletRef string) string {
|
|||||||
// SignTransaction loads the key material from Vault and signs the transaction.
|
// SignTransaction loads the key material from Vault and signs the transaction.
|
||||||
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||||
if strings.TrimSpace(keyID) == "" {
|
if strings.TrimSpace(keyID) == "" {
|
||||||
m.logger.Warn("signing failed: empty key id")
|
m.logger.Warn("Signing failed: empty key id")
|
||||||
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
|
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
|
||||||
}
|
}
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
m.logger.Warn("signing failed: nil transaction", zap.String("key_id", keyID))
|
m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
|
||||||
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
|
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
|
||||||
}
|
}
|
||||||
if chainID == nil {
|
if chainID == nil {
|
||||||
m.logger.Warn("signing failed: nil chain id", zap.String("key_id", keyID))
|
m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
|
||||||
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
|
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
material, err := m.loadKey(ctx, keyID)
|
material, err := m.loadKey(ctx, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
||||||
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
|
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
|
||||||
}
|
}
|
||||||
defer zeroBytes(keyBytes)
|
defer zeroBytes(keyBytes)
|
||||||
|
|
||||||
privateKey, err := crypto.ToECDSA(keyBytes)
|
privateKey, err := crypto.ToECDSA(keyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
||||||
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
|
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
||||||
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
|
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
|
||||||
}
|
}
|
||||||
m.logger.Info("transaction signed with managed key",
|
m.logger.Info("Transaction signed with managed key",
|
||||||
zap.String("key_id", keyID),
|
zap.String("key_id", keyID),
|
||||||
zap.String("network", material.Network),
|
zap.String("network", material.Network),
|
||||||
zap.String("tx_hash", signed.Hash().Hex()),
|
zap.String("tx_hash", signed.Hash().Hex()),
|
||||||
@@ -213,23 +213,23 @@ func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, erro
|
|||||||
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
|
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
|
||||||
secret, err := m.store.Get(ctx, secretPath)
|
secret, err := m.store.Get(ctx, secretPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to read secret", zap.String("path", secretPath), zap.Error(err))
|
m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err))
|
||||||
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
|
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
|
||||||
}
|
}
|
||||||
if secret == nil || secret.Data == nil {
|
if secret == nil || secret.Data == nil {
|
||||||
m.logger.Warn("secret not found", zap.String("path", secretPath))
|
m.logger.Warn("Secret not found", zap.String("path", secretPath))
|
||||||
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
|
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
getString := func(key string) (string, error) {
|
getString := func(key string) (string, error) {
|
||||||
val, ok := secret.Data[key]
|
val, ok := secret.Data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logger.Warn("secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
||||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
|
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
|
||||||
}
|
}
|
||||||
str, ok := val.(string)
|
str, ok := val.(string)
|
||||||
if !ok || strings.TrimSpace(str) == "" {
|
if !ok || strings.TrimSpace(str) == "" {
|
||||||
m.logger.Warn("secret field invalid", zap.String("path", secretPath), zap.String("field", key))
|
m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key))
|
||||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
|
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
|
||||||
}
|
}
|
||||||
return str, nil
|
return str, nil
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package serverimp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
||||||
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
||||||
@@ -30,6 +34,9 @@ type Imp struct {
|
|||||||
|
|
||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
|
||||||
|
rpcClients *rpcclient.Clients
|
||||||
|
service *gatewayservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -41,11 +48,12 @@ type config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type chainConfig struct {
|
type chainConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
RPCURLEnv string `yaml:"rpc_url_env"`
|
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||||
ChainID uint64 `yaml:"chain_id"`
|
ChainID uint64 `yaml:"chain_id"`
|
||||||
NativeToken string `yaml:"native_token"`
|
NativeToken string `yaml:"native_token"`
|
||||||
Tokens []tokenConfig `yaml:"tokens"`
|
Tokens []tokenConfig `yaml:"tokens"`
|
||||||
|
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceWalletConfig struct {
|
type serviceWalletConfig struct {
|
||||||
@@ -61,6 +69,19 @@ type tokenConfig struct {
|
|||||||
ContractEnv string `yaml:"contract_env"`
|
ContractEnv string `yaml:"contract_env"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gasTopUpPolicyConfig struct {
|
||||||
|
gasTopUpRuleConfig `yaml:",inline"`
|
||||||
|
Native *gasTopUpRuleConfig `yaml:"native"`
|
||||||
|
Contract *gasTopUpRuleConfig `yaml:"contract"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gasTopUpRuleConfig struct {
|
||||||
|
BufferPercent float64 `yaml:"buffer_percent"`
|
||||||
|
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
|
||||||
|
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
|
||||||
|
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
|
||||||
|
}
|
||||||
|
|
||||||
// Create initialises the chain gateway server implementation.
|
// Create initialises the chain gateway server implementation.
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
return &Imp{
|
return &Imp{
|
||||||
@@ -80,10 +101,17 @@ func (i *Imp) Shutdown() {
|
|||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
|
if i.rpcClients != nil {
|
||||||
|
i.rpcClients.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
@@ -98,23 +126,39 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cl := i.logger.Named("config")
|
cl := i.logger.Named("config")
|
||||||
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Invalid chain network configuration", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to prepare rpc clients", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.rpcClients = rpcClients
|
||||||
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
||||||
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
|
|
||||||
opts := []gatewayservice.Option{
|
opts := []gatewayservice.Option{
|
||||||
gatewayservice.WithNetworks(networkConfigs),
|
gatewayservice.WithNetworks(networkConfigs),
|
||||||
gatewayservice.WithServiceWallet(walletConfig),
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
gatewayservice.WithKeyManager(keyManager),
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
gatewayservice.WithTransferExecutor(executor),
|
gatewayservice.WithRPCClients(rpcClients),
|
||||||
|
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||||
gatewayservice.WithSettings(cfg.Settings),
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
svc := gatewayservice.NewService(logger, repo, producer, opts...)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
@@ -129,7 +173,7 @@ func (i *Imp) Start() error {
|
|||||||
func (i *Imp) loadConfig() (*config, error) {
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +181,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
Config: &grpcapp.Config{},
|
Config: &grpcapp.Config{},
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
i.logger.Error("failed to parse configuration", zap.Error(err))
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,22 +201,23 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
|
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
|
||||||
result := make([]gatewayshared.Network, 0, len(chains))
|
result := make([]gatewayshared.Network, 0, len(chains))
|
||||||
for _, chain := range chains {
|
for _, chain := range chains {
|
||||||
if strings.TrimSpace(chain.Name) == "" {
|
if strings.TrimSpace(chain.Name) == "" {
|
||||||
logger.Warn("skipping unnamed chain configuration")
|
logger.Warn("Skipping unnamed chain configuration")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
|
||||||
}
|
}
|
||||||
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
||||||
for _, token := range chain.Tokens {
|
for _, token := range chain.Tokens {
|
||||||
symbol := strings.TrimSpace(token.Symbol)
|
symbol := strings.TrimSpace(token.Symbol)
|
||||||
if symbol == "" {
|
if symbol == "" {
|
||||||
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name))
|
logger.Warn("Skipping token with empty symbol", zap.String("chain", chain.Name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
addr := strings.TrimSpace(token.Contract)
|
addr := strings.TrimSpace(token.Contract)
|
||||||
@@ -182,9 +227,9 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
|||||||
}
|
}
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
if env != "" {
|
if env != "" {
|
||||||
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
||||||
} else {
|
} else {
|
||||||
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -194,15 +239,84 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, gatewayshared.Network{
|
result = append(result, gatewayshared.Network{
|
||||||
Name: chain.Name,
|
Name: chain.Name,
|
||||||
RPCURL: rpcURL,
|
RPCURL: rpcURL,
|
||||||
ChainID: chain.ChainID,
|
ChainID: chain.ChainID,
|
||||||
NativeToken: chain.NativeToken,
|
NativeToken: chain.NativeToken,
|
||||||
TokenConfigs: contracts,
|
TokenConfigs: contracts,
|
||||||
|
GasTopUpPolicy: gasPolicy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !defaultSet {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &gatewayshared.GasTopUpPolicy{
|
||||||
|
Default: defaultRule,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Native != nil {
|
||||||
|
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
policy.Native = &rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Contract != nil {
|
||||||
|
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
policy.Contract = &rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
|
||||||
|
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, false, nil
|
||||||
|
}
|
||||||
|
if cfg.BufferPercent < 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.MinNativeBalanceTRX < 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.RoundingUnitTRX <= 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.MaxTopUpTRX <= 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
|
||||||
|
}
|
||||||
|
return gatewayshared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
|
||||||
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||||
@@ -215,13 +329,13 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
|
|||||||
|
|
||||||
if address == "" {
|
if address == "" {
|
||||||
if cfg.AddressEnv != "" {
|
if cfg.AddressEnv != "" {
|
||||||
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
||||||
} else {
|
} else {
|
||||||
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain))
|
logger.Warn("Service wallet address not configured", zap.String("chain", cfg.Chain))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if privateKey == "" {
|
if privateKey == "" {
|
||||||
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||||
}
|
}
|
||||||
|
|
||||||
return gatewayshared.ServiceWallet{
|
return gatewayshared.ServiceWallet{
|
||||||
@@ -235,7 +349,7 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
|
|||||||
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
|
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
|
||||||
if driver == "" {
|
if driver == "" {
|
||||||
err := merrors.InvalidArgument("key management driver is not configured")
|
err := merrors.InvalidArgument("key management driver is not configured")
|
||||||
logger.Error("key management driver missing")
|
logger.Error("Key management driver missing")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,19 +358,19 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
|
|||||||
settings := vaultmanager.Config{}
|
settings := vaultmanager.Config{}
|
||||||
if len(cfg.Settings) > 0 {
|
if len(cfg.Settings) > 0 {
|
||||||
if err := mapstructure.Decode(cfg.Settings, &settings); err != nil {
|
if err := mapstructure.Decode(cfg.Settings, &settings); err != nil {
|
||||||
logger.Error("failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
|
logger.Error("Failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
|
||||||
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
|
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
manager, err := vaultmanager.New(logger, settings)
|
manager, err := vaultmanager.New(logger, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to initialise vault key manager", zap.Error(err))
|
logger.Error("Failed to initialise vault key manager", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return manager, nil
|
return manager, nil
|
||||||
default:
|
default:
|
||||||
err := merrors.InvalidArgument("unsupported key management driver: " + driver)
|
err := merrors.InvalidArgument("unsupported key management driver: " + driver)
|
||||||
logger.Error("unsupported key management driver", zap.String("driver", driver))
|
logger.Error("Unsupported key management driver", zap.String("driver", driver))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Registry struct {
|
|||||||
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
||||||
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
||||||
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
||||||
|
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
|
||||||
|
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistryDeps struct {
|
type RegistryDeps struct {
|
||||||
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
|
|||||||
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
||||||
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
||||||
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
||||||
|
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
|
||||||
|
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package transfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
@@ -11,9 +14,11 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||||
if managedRef != "" && external != "" {
|
if managedRef != "" && external != "" {
|
||||||
deps.Logger.Warn("both managed and external destination provided")
|
deps.Logger.Warn("Both managed and external destination provided")
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||||
}
|
}
|
||||||
if managedRef != "" {
|
if managedRef != "" {
|
||||||
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||||
return model.TransferDestination{}, err
|
return model.TransferDestination{}, err
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||||
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
@@ -40,11 +40,26 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if external == "" {
|
if external == "" {
|
||||||
deps.Logger.Warn("destination external address missing")
|
deps.Logger.Warn("Destination external address missing")
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
}
|
}
|
||||||
|
if deps.Drivers == nil {
|
||||||
|
deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network))
|
||||||
|
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
normalized, err := chainDriver.NormalizeAddress(external)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("Invalid external address", zap.Error(err))
|
||||||
|
return model.TransferDestination{}, err
|
||||||
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
ExternalAddress: strings.ToLower(external),
|
ExternalAddress: normalized,
|
||||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
ExternalAddressOriginal: external,
|
||||||
|
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
|
func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,13 @@ package transfer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -33,216 +24,94 @@ func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
|
|||||||
|
|
||||||
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
|
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
c.deps.Logger.Warn("nil request")
|
c.deps.Logger.Warn("Empty request received")
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
if sourceWalletRef == "" {
|
if sourceWalletRef == "" {
|
||||||
c.deps.Logger.Warn("source wallet ref missing")
|
c.deps.Logger.Warn("Source wallet ref missing")
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
}
|
}
|
||||||
amount := req.GetAmount()
|
amount := req.GetAmount()
|
||||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||||
c.deps.Logger.Warn("amount missing or incomplete")
|
c.deps.Logger.Warn("Amount missing or incomplete")
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
}
|
||||||
|
if c.deps.Drivers == nil {
|
||||||
|
c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey))
|
||||||
|
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
|
}
|
||||||
|
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
}
|
}
|
||||||
|
|
||||||
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
|
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
c.deps.Logger.Warn("Failed to resolve destination address", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
|
walletForFee := sourceWallet
|
||||||
|
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||||
|
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
|
||||||
|
copyWallet := *sourceWallet
|
||||||
|
copyWallet.ContractAddress = ""
|
||||||
|
copyWallet.TokenSymbol = nativeCurrency
|
||||||
|
walletForFee = ©Wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
driverDeps := driver.Deps{
|
||||||
|
Logger: c.deps.Logger,
|
||||||
|
Registry: c.deps.Networks,
|
||||||
|
RPCTimeout: c.deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
c.deps.Logger.Warn("Fee estimation failed", zap.Error(err))
|
||||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contextLabel := "erc20_transfer"
|
||||||
|
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
|
||||||
|
contextLabel = "native_transfer"
|
||||||
|
}
|
||||||
resp := &chainv1.EstimateTransferFeeResponse{
|
resp := &chainv1.EstimateTransferFeeResponse{
|
||||||
NetworkFee: feeMoney,
|
NetworkFee: feeMoney,
|
||||||
EstimationContext: "erc20_transfer",
|
EstimationContext: contextLabel,
|
||||||
}
|
}
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
|
||||||
if rpcURL == "" {
|
|
||||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(wallet.ContractAddress) == "" {
|
|
||||||
return nil, merrors.NotImplemented("native token transfers not supported")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.ContractAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.DepositAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(destination) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid destination address")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
|
||||||
toAddr := common.HexToAddress(destination)
|
|
||||||
fromAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("failed to read token decimals", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
callMsg := ethereum.CallMsg{
|
|
||||||
From: fromAddr,
|
|
||||||
To: &tokenAddr,
|
|
||||||
GasPrice: gasPrice,
|
|
||||||
Data: input,
|
|
||||||
}
|
|
||||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
|
||||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
|
||||||
|
|
||||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
|
||||||
if currency == "" {
|
|
||||||
currency = strings.ToUpper(network.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: currency,
|
|
||||||
Amount: feeDec.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
callData, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{
|
|
||||||
To: &token,
|
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", output)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
|
|
||||||
}
|
|
||||||
if len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("decimals call returned no data")
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, merrors.Internal("decimals call returned unexpected type")
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
|
||||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
|
||||||
}
|
|
||||||
if value.IsNegative() {
|
|
||||||
return nil, merrors.InvalidArgument("amount must be positive")
|
|
||||||
}
|
|
||||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
|
||||||
scaled := value.Mul(multiplier)
|
|
||||||
if !scaled.Equal(scaled.Truncate(0)) {
|
|
||||||
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
|
||||||
}
|
|
||||||
return scaled.BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20TransferABI = `
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [],
|
|
||||||
"name": "decimals",
|
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": false,
|
|
||||||
"inputs": [
|
|
||||||
{ "name": "_to", "type": "address" },
|
|
||||||
{ "name": "_value", "type": "uint256" }
|
|
||||||
],
|
|
||||||
"name": "transfer",
|
|
||||||
"outputs": [{ "name": "", "type": "bool" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "nonpayable",
|
|
||||||
"type": "function"
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type computeGasTopUpCommand struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
|
||||||
|
return &computeGasTopUpCommand{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
|
||||||
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
c.deps.Logger.Warn("Nil request")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
|
if walletRef == "" {
|
||||||
|
c.deps.Logger.Warn("Wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
|
}
|
||||||
|
estimatedFee := req.GetEstimatedTotalFee()
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
c.deps.Logger.Warn("Estimated fee missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||||
|
|
||||||
|
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
|
||||||
|
TopupAmount: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ensureGasTopUpCommand struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
|
||||||
|
return &ensureGasTopUpCommand{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
|
||||||
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
c.deps.Logger.Warn("Nil request")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
c.deps.Logger.Warn("Idempotency key missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
|
}
|
||||||
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
if organizationRef == "" {
|
||||||
|
c.deps.Logger.Warn("Organization ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
|
}
|
||||||
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
if sourceWalletRef == "" {
|
||||||
|
c.deps.Logger.Warn("Source wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
|
||||||
|
if targetWalletRef == "" {
|
||||||
|
c.deps.Logger.Warn("Target wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
estimatedFee := req.GetEstimatedTotalFee()
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
c.deps.Logger.Warn("Estimated fee missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||||
|
|
||||||
|
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
|
||||||
|
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: nil,
|
||||||
|
CapHit: capHit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReq := &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
OrganizationRef: organizationRef,
|
||||||
|
SourceWalletRef: sourceWalletRef,
|
||||||
|
Destination: &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
|
||||||
|
},
|
||||||
|
Amount: topUp,
|
||||||
|
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||||
|
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||||
|
}
|
||||||
|
|
||||||
|
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
|
||||||
|
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
submitResp, err := submitResponder(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
Transfer: submitResp.GetTransfer(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
|
||||||
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
|
estimatedFee = shared.CloneMoney(estimatedFee)
|
||||||
|
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
|
||||||
|
networkCfg, ok := deps.Networks.Network(networkKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(networkKey, "tron") {
|
||||||
|
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if networkCfg.GasTopUpPolicy != nil {
|
||||||
|
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, capHit, nil, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, false, nil, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
if walletModel == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
walletDeps := wallet.Deps{
|
||||||
|
Logger: deps.Logger.Named("wallet"),
|
||||||
|
Drivers: deps.Drivers,
|
||||||
|
Networks: deps.Networks,
|
||||||
|
KeyManager: nil,
|
||||||
|
Storage: deps.Storage,
|
||||||
|
Clock: deps.Clock,
|
||||||
|
BalanceCacheTTL: 0,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
EnsureRepository: deps.EnsureRepository,
|
||||||
|
}
|
||||||
|
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("native balance is unavailable")
|
||||||
|
}
|
||||||
|
return nativeBalance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("native balance is required")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
|
||||||
|
return nil, merrors.InvalidArgument("native balance currency mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
required := estimated.Sub(current)
|
||||||
|
if !required.IsPositive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
|
||||||
|
Amount: required.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
|
||||||
|
if logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", walletRef),
|
||||||
|
zap.String("estimated_total_fee", amountString(estimatedFee)),
|
||||||
|
zap.String("current_native_balance", amountString(nativeBalance)),
|
||||||
|
zap.String("topup_amount", amountString(topUp)),
|
||||||
|
zap.Bool("cap_hit", capHit),
|
||||||
|
}
|
||||||
|
if walletModel != nil {
|
||||||
|
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
|
||||||
|
}
|
||||||
|
if decision != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
|
||||||
|
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
|
||||||
|
zap.String("required_trx", decision.RequiredTRX.String()),
|
||||||
|
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
|
||||||
|
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
|
||||||
|
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
|
||||||
|
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
|
||||||
|
zap.String("topup_trx", decision.TopUpTRX.String()),
|
||||||
|
zap.String("operation_type", decision.OperationType),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.Info("Gas top-up decision", fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountString(m *moneyv1.Money) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
if amount == "" && currency == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
return amount + " " + currency
|
||||||
|
}
|
||||||
@@ -22,25 +22,25 @@ func NewGetTransfer(deps Deps) *getTransferCommand {
|
|||||||
|
|
||||||
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
|
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
c.deps.Logger.Warn("nil request")
|
c.deps.Logger.Warn("Nil request")
|
||||||
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
}
|
}
|
||||||
transferRef := strings.TrimSpace(req.GetTransferRef())
|
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||||
if transferRef == "" {
|
if transferRef == "" {
|
||||||
c.deps.Logger.Warn("transfer_ref missing")
|
c.deps.Logger.Warn("Transfer_ref missing")
|
||||||
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
||||||
}
|
}
|
||||||
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
|
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef))
|
c.deps.Logger.Warn("Not found", zap.String("transfer_ref", transferRef))
|
||||||
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
||||||
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
|
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func NewListTransfers(deps Deps) *listTransfersCommand {
|
|||||||
|
|
||||||
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
|
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
filter := model.TransferFilter{}
|
filter := model.TransferFilter{}
|
||||||
@@ -41,7 +41,7 @@ func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTra
|
|||||||
|
|
||||||
result, err := c.deps.Storage.Transfers().List(ctx, filter)
|
result, err := c.deps.Storage.Transfers().List(ctx, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
c.deps.Logger.Warn("Storage list failed", zap.Error(err))
|
||||||
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,94 +25,102 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
|
|||||||
|
|
||||||
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
c.deps.Logger.Warn("nil request")
|
c.deps.Logger.Warn("Nil request")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
}
|
}
|
||||||
|
|
||||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
if idempotencyKey == "" {
|
if idempotencyKey == "" {
|
||||||
c.deps.Logger.Warn("missing idempotency key")
|
c.deps.Logger.Warn("Missing idempotency key")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
}
|
}
|
||||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
if organizationRef == "" {
|
if organizationRef == "" {
|
||||||
c.deps.Logger.Warn("missing organization ref")
|
c.deps.Logger.Warn("Missing organization ref")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
}
|
}
|
||||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
if sourceWalletRef == "" {
|
if sourceWalletRef == "" {
|
||||||
c.deps.Logger.Warn("missing source wallet ref")
|
c.deps.Logger.Warn("Missing source wallet ref")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
}
|
}
|
||||||
amount := req.GetAmount()
|
amount := req.GetAmount()
|
||||||
if amount == nil {
|
if amount == nil {
|
||||||
c.deps.Logger.Warn("missing amount")
|
c.deps.Logger.Warn("Missing amount")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||||
}
|
}
|
||||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||||
if amountCurrency == "" {
|
if amountCurrency == "" {
|
||||||
c.deps.Logger.Warn("missing amount currency")
|
c.deps.Logger.Warn("Missing amount currency")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||||
}
|
}
|
||||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||||
if amountValue == "" {
|
if amountValue == "" {
|
||||||
c.deps.Logger.Warn("missing amount value")
|
c.deps.Logger.Warn("Missing amount value")
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||||
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||||
}
|
}
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
}
|
}
|
||||||
|
|
||||||
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
|
c.deps.Logger.Warn("Fee conversion failed", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
amountDec, err := decimal.NewFromString(amountValue)
|
amountDec, err := decimal.NewFromString(amountValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("invalid amount", zap.Error(err))
|
c.deps.Logger.Warn("Invalid amount", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||||
}
|
}
|
||||||
netDec := amountDec.Sub(feeSum)
|
netDec := amountDec.Sub(feeSum)
|
||||||
if netDec.IsNegative() {
|
if netDec.IsNegative() {
|
||||||
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||||
}
|
}
|
||||||
|
|
||||||
netAmount := shared.CloneMoney(amount)
|
netAmount := shared.CloneMoney(amount)
|
||||||
netAmount.Amount = netDec.String()
|
netAmount.Amount = netDec.String()
|
||||||
|
|
||||||
|
effectiveTokenSymbol := sourceWallet.TokenSymbol
|
||||||
|
effectiveContractAddress := sourceWallet.ContractAddress
|
||||||
|
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||||
|
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
|
||||||
|
effectiveTokenSymbol = nativeCurrency
|
||||||
|
effectiveContractAddress = ""
|
||||||
|
}
|
||||||
|
|
||||||
transfer := &model.Transfer{
|
transfer := &model.Transfer{
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
TransferRef: shared.GenerateTransferRef(),
|
TransferRef: shared.GenerateTransferRef(),
|
||||||
@@ -120,8 +128,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
SourceWalletRef: sourceWalletRef,
|
SourceWalletRef: sourceWalletRef,
|
||||||
Destination: destination,
|
Destination: destination,
|
||||||
Network: sourceWallet.Network,
|
Network: sourceWallet.Network,
|
||||||
TokenSymbol: sourceWallet.TokenSymbol,
|
TokenSymbol: effectiveTokenSymbol,
|
||||||
ContractAddress: sourceWallet.ContractAddress,
|
ContractAddress: effectiveContractAddress,
|
||||||
RequestedAmount: shared.CloneMoney(amount),
|
RequestedAmount: shared.CloneMoney(amount),
|
||||||
NetAmount: netAmount,
|
NetAmount: netAmount,
|
||||||
Fees: fees,
|
Fees: fees,
|
||||||
@@ -133,10 +141,10 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||||
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,41 +29,41 @@ func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
|
|||||||
|
|
||||||
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
|
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
c.deps.Logger.Warn("nil request")
|
c.deps.Logger.Warn("Nil request")
|
||||||
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
}
|
}
|
||||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
if walletRef == "" {
|
if walletRef == "" {
|
||||||
c.deps.Logger.Warn("wallet_ref missing")
|
c.deps.Logger.Warn("Wallet_ref missing")
|
||||||
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
}
|
}
|
||||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
|
||||||
if chainErr != nil {
|
if chainErr != nil {
|
||||||
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("On-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||||
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("Cached balance not found", zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||||
}
|
}
|
||||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if c.isCachedBalanceStale(stored) {
|
if c.isCachedBalanceStale(stored) {
|
||||||
c.deps.Logger.Warn("cached balance is stale",
|
c.deps.Logger.Info("Cached balance is stale",
|
||||||
zap.String("wallet_ref", walletRef),
|
zap.String("wallet_ref", walletRef),
|
||||||
zap.Time("calculated_at", stored.CalculatedAt),
|
zap.Time("calculated_at", stored.CalculatedAt),
|
||||||
zap.Duration("ttl", c.cacheTTL()),
|
zap.Duration("ttl", c.cacheTTL()),
|
||||||
@@ -74,39 +74,49 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
|||||||
}
|
}
|
||||||
|
|
||||||
calculatedAt := c.now()
|
calculatedAt := c.now()
|
||||||
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
|
||||||
|
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||||
Balance: onChainBalanceToProto(balance, calculatedAt),
|
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||||
if balance == nil {
|
if balance == nil && native == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
zero := zeroMoney(balance.Currency)
|
currency := ""
|
||||||
|
if balance != nil {
|
||||||
|
currency = balance.Currency
|
||||||
|
}
|
||||||
|
zero := zeroMoney(currency)
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: balance,
|
Available: balance,
|
||||||
|
NativeAvailable: native,
|
||||||
PendingInbound: zero,
|
PendingInbound: zero,
|
||||||
PendingOutbound: zero,
|
PendingOutbound: zero,
|
||||||
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
|
||||||
if available == nil {
|
if available == nil && nativeAvailable == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record := &model.WalletBalance{
|
record := &model.WalletBalance{
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
Available: shared.CloneMoney(available),
|
Available: shared.CloneMoney(available),
|
||||||
PendingInbound: zeroMoney(available.Currency),
|
NativeAvailable: shared.CloneMoney(nativeAvailable),
|
||||||
PendingOutbound: zeroMoney(available.Currency),
|
|
||||||
CalculatedAt: calculatedAt,
|
CalculatedAt: calculatedAt,
|
||||||
}
|
}
|
||||||
|
currency := ""
|
||||||
|
if available != nil {
|
||||||
|
currency = available.Currency
|
||||||
|
}
|
||||||
|
record.PendingInbound = zeroMoney(currency)
|
||||||
|
record.PendingOutbound = zeroMoney(currency)
|
||||||
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
||||||
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
c.deps.Logger.Warn("Failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -24,78 +25,118 @@ func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
|
|||||||
|
|
||||||
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
|
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
c.deps.Logger.Warn("nil request")
|
c.deps.Logger.Warn("Nil request")
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
}
|
}
|
||||||
|
|
||||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
if idempotencyKey == "" {
|
if idempotencyKey == "" {
|
||||||
c.deps.Logger.Warn("missing idempotency key")
|
c.deps.Logger.Warn("Missing idempotency key")
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
}
|
}
|
||||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
if organizationRef == "" {
|
if organizationRef == "" {
|
||||||
c.deps.Logger.Warn("missing organization ref")
|
c.deps.Logger.Warn("Missing organization ref")
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
}
|
}
|
||||||
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||||
if ownerRef == "" {
|
if ownerRef == "" {
|
||||||
c.deps.Logger.Warn("missing owner ref")
|
c.deps.Logger.Warn("Missing owner ref")
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
asset := req.GetAsset()
|
asset := req.GetAsset()
|
||||||
if asset == nil {
|
if asset == nil {
|
||||||
c.deps.Logger.Warn("missing asset")
|
c.deps.Logger.Warn("Missing asset")
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
|
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
|
||||||
if chainKey == "" {
|
if chainKey == "" {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
networkCfg, ok := c.deps.Networks[chainKey]
|
networkCfg, ok := c.deps.Networks.Network(chainKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", chainKey))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
|
}
|
||||||
|
if c.deps.Drivers == nil {
|
||||||
|
c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey))
|
||||||
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
|
}
|
||||||
|
chainDriver, err := c.deps.Drivers.Driver(chainKey)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
if tokenSymbol == "" {
|
if tokenSymbol == "" {
|
||||||
c.deps.Logger.Warn("missing token symbol")
|
c.deps.Logger.Warn("Missing token symbol")
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||||
}
|
}
|
||||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||||
if contractAddress == "" {
|
if contractAddress == "" {
|
||||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
|
||||||
if contractAddress == "" {
|
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||||
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
if contractAddress == "" {
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
walletRef := shared.GenerateWalletRef()
|
walletRef := shared.GenerateWalletRef()
|
||||||
if c.deps.KeyManager == nil {
|
if c.deps.KeyManager == nil {
|
||||||
c.deps.Logger.Warn("key manager missing")
|
c.deps.Logger.Warn("Key manager missing")
|
||||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||||
}
|
}
|
||||||
|
|
||||||
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("key manager error", zap.Error(err))
|
c.deps.Logger.Warn("Key manager error", zap.Error(err))
|
||||||
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||||
c.deps.Logger.Warn("key manager returned empty address")
|
c.deps.Logger.Warn("Key manager returned empty address")
|
||||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||||
}
|
}
|
||||||
|
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("Invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := shared.CloneMetadata(req.GetMetadata())
|
||||||
|
desc := req.GetDescribable()
|
||||||
|
name := strings.TrimSpace(desc.GetName())
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(metadata["name"])
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if desc != nil && desc.Description != nil {
|
||||||
|
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description == nil {
|
||||||
|
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = walletRef
|
||||||
|
}
|
||||||
|
|
||||||
wallet := &model.ManagedWallet{
|
wallet := &model.ManagedWallet{
|
||||||
|
Describable: pkgmodel.Describable{
|
||||||
|
Name: name,
|
||||||
|
},
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
OrganizationRef: organizationRef,
|
OrganizationRef: organizationRef,
|
||||||
@@ -103,19 +144,22 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
Network: chainKey,
|
Network: chainKey,
|
||||||
TokenSymbol: tokenSymbol,
|
TokenSymbol: tokenSymbol,
|
||||||
ContractAddress: contractAddress,
|
ContractAddress: contractAddress,
|
||||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
DepositAddress: depositAddress,
|
||||||
KeyReference: keyInfo.KeyID,
|
KeyReference: keyInfo.KeyID,
|
||||||
Status: model.ManagedWalletStatusActive,
|
Status: model.ManagedWalletStatusActive,
|
||||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
if description != nil {
|
||||||
|
wallet.Describable.Description = description
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
c.deps.Logger.Debug("Wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||||
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
|
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -13,17 +14,20 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
KeyManager keymanager.Manager
|
KeyManager keymanager.Manager
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
BalanceCacheTTL time.Duration
|
BalanceCacheTTL time.Duration
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Deps) WithLogger(name string) Deps {
|
func (d Deps) WithLogger(name string) Deps {
|
||||||
if d.Logger != nil {
|
if d.Logger == nil {
|
||||||
d.Logger = d.Logger.Named(name)
|
panic("wallet deps: logger is required")
|
||||||
}
|
}
|
||||||
|
d.Logger = d.Logger.Named(name)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,25 +22,25 @@ func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
|
|||||||
|
|
||||||
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
|
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
if req == nil {
|
if req == nil {
|
||||||
c.deps.Logger.Warn("nil request")
|
c.deps.Logger.Warn("Nil request")
|
||||||
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
}
|
}
|
||||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
if walletRef == "" {
|
if walletRef == "" {
|
||||||
c.deps.Logger.Warn("wallet_ref missing")
|
c.deps.Logger.Warn("Wallet_ref missing")
|
||||||
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
}
|
}
|
||||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
|
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
|
|||||||
|
|
||||||
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
|
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
|
||||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||||
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
filter := model.ManagedWalletFilter{}
|
filter := model.ManagedWalletFilter{}
|
||||||
@@ -42,7 +42,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li
|
|||||||
|
|
||||||
result, err := c.deps.Storage.Wallets().List(ctx, filter)
|
result, err := c.deps.Storage.Wallets().List(ctx, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
c.deps.Logger.Warn("Storage list failed", zap.Error(err))
|
||||||
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,123 +2,61 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math/big"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
|
||||||
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
|
logger := deps.Logger
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
if wallet == nil {
|
||||||
if rpcURL == "" {
|
return nil, nil, merrors.InvalidArgument("wallet is required")
|
||||||
return nil, merrors.Internal("network rpc url is not configured")
|
|
||||||
}
|
}
|
||||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
if deps.Networks == nil {
|
||||||
if contract == "" || !common.IsHexAddress(contract) {
|
return nil, nil, merrors.Internal("rpc clients not initialised")
|
||||||
return nil, merrors.InvalidArgument("invalid contract address")
|
|
||||||
}
|
}
|
||||||
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
|
if deps.Drivers == nil {
|
||||||
return nil, merrors.InvalidArgument("invalid wallet address")
|
return nil, nil, merrors.Internal("chain drivers not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
||||||
if err != nil {
|
network, ok := deps.Networks.Network(networkKey)
|
||||||
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(contract)
|
|
||||||
walletAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
|
||||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
data, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals")
|
|
||||||
}
|
|
||||||
if val, ok := values[0].(uint8); ok {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
return 0, merrors.Internal("decimals returned unexpected type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
|
|
||||||
data, err := tokenABI.Pack("balanceOf", wallet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("balanceOf", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return nil, merrors.Internal("failed to unpack balanceOf")
|
|
||||||
}
|
|
||||||
raw, ok := values[0].(*big.Int)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, merrors.Internal("balanceOf returned unexpected type")
|
logger.Warn("Requested network is not configured",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", networkKey),
|
||||||
|
)
|
||||||
|
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||||
}
|
}
|
||||||
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20ABIJSON = `
|
chainDriver, err := deps.Drivers.Driver(networkKey)
|
||||||
[
|
if err != nil {
|
||||||
{
|
logger.Warn("Chain driver not configured",
|
||||||
"constant": true,
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
"inputs": [],
|
zap.String("network", networkKey),
|
||||||
"name": "decimals",
|
zap.Error(err),
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
)
|
||||||
"payable": false,
|
return nil, nil, merrors.InvalidArgument("unsupported chain")
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [{ "name": "_owner", "type": "address" }],
|
|
||||||
"name": "balanceOf",
|
|
||||||
"outputs": [{ "name": "balance", "type": "uint256" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
}
|
}
|
||||||
]`
|
|
||||||
|
driverDeps := driver.Deps{
|
||||||
|
Logger: deps.Logger,
|
||||||
|
Registry: deps.Networks,
|
||||||
|
KeyManager: deps.KeyManager,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tokenBalance, nativeBalance, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package wallet
|
package wallet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
TokenSymbol: wallet.TokenSymbol,
|
TokenSymbol: wallet.TokenSymbol,
|
||||||
ContractAddress: wallet.ContractAddress,
|
ContractAddress: wallet.ContractAddress,
|
||||||
}
|
}
|
||||||
|
name := strings.TrimSpace(wallet.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(wallet.Metadata["name"])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = wallet.WalletRef
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
switch {
|
||||||
|
case wallet.Description != nil:
|
||||||
|
description = strings.TrimSpace(*wallet.Description)
|
||||||
|
default:
|
||||||
|
description = strings.TrimSpace(wallet.Metadata["description"])
|
||||||
|
}
|
||||||
|
desc := &describablev1.Describable{Name: name}
|
||||||
|
if description != "" {
|
||||||
|
desc.Description = &description
|
||||||
|
}
|
||||||
|
|
||||||
return &chainv1.ManagedWallet{
|
return &chainv1.ManagedWallet{
|
||||||
WalletRef: wallet.WalletRef,
|
WalletRef: wallet.WalletRef,
|
||||||
OrganizationRef: wallet.OrganizationRef,
|
OrganizationRef: wallet.OrganizationRef,
|
||||||
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
Metadata: shared.CloneMetadata(wallet.Metadata),
|
Metadata: shared.CloneMetadata(wallet.Metadata),
|
||||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||||
|
Describable: desc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
|
|||||||
}
|
}
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: shared.CloneMoney(balance.Available),
|
Available: shared.CloneMoney(balance.Available),
|
||||||
|
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
|
||||||
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
||||||
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
||||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package arbitrum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Arbitrum-specific behavior using the shared EVM logic.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("arbitrum")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "arbitrum"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Format address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("Balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("Native balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Native balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("Estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Estimate fee result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
d.logger.Debug("Submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer failed",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("Submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("Await confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Await confirmation failed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("Await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package driver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps bundles dependencies shared across chain drivers.
|
||||||
|
type Deps struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
Registry *rpcclient.Registry
|
||||||
|
KeyManager keymanager.Manager
|
||||||
|
RPCTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver defines chain-specific behavior for wallet and transfer operations.
|
||||||
|
type Driver interface {
|
||||||
|
Name() string
|
||||||
|
FormatAddress(address string) (string, error)
|
||||||
|
NormalizeAddress(address string) (string, error)
|
||||||
|
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||||
|
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||||
|
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
|
||||||
|
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
|
||||||
|
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Ethereum-specific behavior using the shared EVM logic.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("ethereum")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "ethereum"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Format address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("Balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("Native balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Native balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("Estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Estimate fee result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
d.logger.Debug("Submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer failed",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("Submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("Await confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Await confirmation failed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("Await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTronEstimateCallUsesData(t *testing.T) {
|
||||||
|
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
|
||||||
|
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: from,
|
||||||
|
To: &to,
|
||||||
|
GasPrice: big.NewInt(100),
|
||||||
|
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
|
||||||
|
}
|
||||||
|
|
||||||
|
call := tronEstimateCall(callMsg)
|
||||||
|
|
||||||
|
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
|
||||||
|
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
|
||||||
|
require.Equal(t, "0x64", call["gasPrice"])
|
||||||
|
require.Equal(t, "0xa9059cbb", call["data"])
|
||||||
|
_, hasInput := call["input"]
|
||||||
|
require.False(t, hasInput)
|
||||||
|
}
|
||||||
747
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
747
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
erc20ABI abi.ABI
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
|
||||||
|
if err != nil {
|
||||||
|
panic("evm driver: failed to parse erc20 abi: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const erc20ABIJSON = `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"constant": false,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "_to", "type": "address" },
|
||||||
|
{ "name": "_value", "type": "uint256" }
|
||||||
|
],
|
||||||
|
"name": "transfer",
|
||||||
|
"outputs": [{ "name": "", "type": "bool" }],
|
||||||
|
"payable": false,
|
||||||
|
"stateMutability": "nonpayable",
|
||||||
|
"type": "function"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"constant": true,
|
||||||
|
"inputs": [],
|
||||||
|
"name": "decimals",
|
||||||
|
"outputs": [{ "name": "", "type": "uint8" }],
|
||||||
|
"payable": false,
|
||||||
|
"stateMutability": "view",
|
||||||
|
"type": "function"
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
// NormalizeAddress validates and normalizes EVM hex addresses.
|
||||||
|
func NormalizeAddress(address string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(trimmed) {
|
||||||
|
return "", merrors.InvalidArgument("invalid hex address")
|
||||||
|
}
|
||||||
|
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeCurrency(network shared.Network) string {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.ToUpper(network.Name)
|
||||||
|
}
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBaseUnitAmount(amount string) (*big.Int, error) {
|
||||||
|
trimmed := strings.TrimSpace(amount)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
value, ok := new(big.Int).SetString(trimmed, 10)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("invalid amount")
|
||||||
|
}
|
||||||
|
if value.Sign() < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("amount must be non-negative")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance fetches ERC20 token balance for the provided address.
|
||||||
|
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||||
|
logger := deps.Logger.Named("evm")
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedAddress, err := NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
logFields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
|
||||||
|
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
|
||||||
|
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
|
||||||
|
zap.String("wallet_address", normalizedAddress),
|
||||||
|
}
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url is not configured", logFields...)
|
||||||
|
return nil, merrors.Internal("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||||
|
if contract == "" {
|
||||||
|
logger.Debug("Native balance requested", logFields...)
|
||||||
|
return NativeBalance(ctx, deps, network, wallet, normalizedAddress)
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(contract) {
|
||||||
|
logger.Warn("Invalid contract address for balance fetch", logFields...)
|
||||||
|
return nil, merrors.InvalidArgument("invalid contract address")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Fetching on-chain wallet balance", logFields...)
|
||||||
|
|
||||||
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := deps.RPCTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger.Debug("Calling token decimals", logFields...)
|
||||||
|
decimals, err := readDecimals(timeoutCtx, rpcClient, contract)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
|
||||||
|
bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, normalizedAddress)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
||||||
|
logger.Info("On-chain wallet balance fetched",
|
||||||
|
append(logFields,
|
||||||
|
zap.Uint8("decimals", decimals),
|
||||||
|
zap.String("balance_raw", bal.String()),
|
||||||
|
zap.String("balance", dec.String()),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeBalance fetches native token balance for the provided address.
|
||||||
|
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||||
|
logger := deps.Logger.Named("evm")
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedAddress, err := NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
logFields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
|
||||||
|
zap.String("wallet_address", normalizedAddress),
|
||||||
|
}
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url is not configured", logFields...)
|
||||||
|
return nil, merrors.Internal("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := deps.RPCTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("On-chain native balance fetched",
|
||||||
|
append(logFields,
|
||||||
|
zap.String("balance_raw", bal.String()),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: bal.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateFee estimates ERC20 transfer fees for the given parameters.
|
||||||
|
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
logger := deps.Logger.Named("evm")
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if amount == nil {
|
||||||
|
return nil, merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
return nil, merrors.InvalidArgument("network rpc url not configured")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid source wallet address")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(destination); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid destination address")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := deps.RPCTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||||
|
toAddr := common.HexToAddress(destination)
|
||||||
|
fromAddr := common.HexToAddress(fromAddress)
|
||||||
|
|
||||||
|
if contract == "" {
|
||||||
|
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||||
|
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||||
|
}
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: fromAddr,
|
||||||
|
To: &toAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Value: amountBase,
|
||||||
|
}
|
||||||
|
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg))
|
||||||
|
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||||
|
}
|
||||||
|
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||||
|
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: feeDec.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(contract) {
|
||||||
|
logger.Warn("Failed to validate contract", zap.String("contract", contract))
|
||||||
|
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenAddr := common.HexToAddress(contract)
|
||||||
|
|
||||||
|
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to read token decimals", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to encode transfer call", zap.Error(err))
|
||||||
|
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||||
|
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: fromAddr,
|
||||||
|
To: &tokenAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Data: input,
|
||||||
|
}
|
||||||
|
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
|
||||||
|
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||||
|
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||||
|
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: feeDec.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
|
||||||
|
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
|
||||||
|
logger := deps.Logger.Named("evm")
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if deps.KeyManager == nil {
|
||||||
|
logger.Warn("Key manager not configured")
|
||||||
|
return "", executorInternal("key manager is not configured", nil)
|
||||||
|
}
|
||||||
|
if registry == nil {
|
||||||
|
return "", executorInternal("rpc clients not initialised", nil)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
||||||
|
return "", executorInvalid("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
if source == nil || transfer == nil {
|
||||||
|
logger.Warn("Transfer context missing")
|
||||||
|
return "", executorInvalid("transfer context missing")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.KeyReference) == "" {
|
||||||
|
logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||||
|
return "", executorInvalid("source wallet missing key reference")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||||
|
logger.Warn("Invalid source wallet address", zap.String("wallet_ref", source.WalletRef))
|
||||||
|
return "", executorInvalid("invalid source wallet address")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeAddress(destination); err != nil {
|
||||||
|
logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destination))
|
||||||
|
return "", executorInvalid("invalid destination address " + destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Submitting transfer",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("source_wallet_ref", source.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", strings.ToLower(destination)),
|
||||||
|
)
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceAddress := common.HexToAddress(fromAddress)
|
||||||
|
destinationAddr := common.HexToAddress(destination)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to suggest gas price", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to suggest gas price", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||||
|
|
||||||
|
contract := strings.TrimSpace(transfer.ContractAddress)
|
||||||
|
amount := transfer.NetAmount
|
||||||
|
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||||
|
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return "", executorInvalid("transfer missing net amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx *types.Transaction
|
||||||
|
if contract == "" {
|
||||||
|
amountInt, err := parseBaseUnitAmount(amount.Amount)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: sourceAddress,
|
||||||
|
To: &destinationAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Value: amountInt,
|
||||||
|
}
|
||||||
|
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to estimate gas", err)
|
||||||
|
}
|
||||||
|
tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil)
|
||||||
|
} else {
|
||||||
|
if !common.IsHexAddress(contract) {
|
||||||
|
logger.Warn("Invalid token contract address",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", contract),
|
||||||
|
)
|
||||||
|
return "", executorInvalid("invalid token contract address " + contract)
|
||||||
|
}
|
||||||
|
tokenAddress := common.HexToAddress(contract)
|
||||||
|
|
||||||
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", contract),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("amount", amount.Amount),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to encode transfer call", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to encode transfer call", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: sourceAddress,
|
||||||
|
To: &tokenAddress,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Data: input,
|
||||||
|
}
|
||||||
|
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to estimate gas", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
|
logger.Warn("Failed to send transaction", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to send transaction", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash := signedTx.Hash().Hex()
|
||||||
|
logger.Info("Transaction submitted",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
|
||||||
|
return txHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwaitConfirmation waits for the transaction receipt.
|
||||||
|
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
logger := deps.Logger.Named("evm")
|
||||||
|
registry := deps.Registry
|
||||||
|
|
||||||
|
if strings.TrimSpace(txHash) == "" {
|
||||||
|
logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||||
|
return nil, executorInvalid("tx hash is required")
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("Network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||||
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
if registry == nil {
|
||||||
|
return nil, executorInternal("rpc clients not initialised", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := common.HexToHash(txHash)
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
receipt, err := client.TransactionReceipt(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ethereum.NotFound) {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
logger.Debug("Transaction not yet mined",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Warn("Context cancelled while awaiting confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Warn("Failed to fetch transaction receipt",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||||
|
}
|
||||||
|
logger.Info("Transaction confirmed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) {
|
||||||
|
call := map[string]string{
|
||||||
|
"to": strings.ToLower(common.HexToAddress(token).Hex()),
|
||||||
|
"data": "0x313ce567",
|
||||||
|
}
|
||||||
|
var hexResp string
|
||||||
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
|
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
||||||
|
}
|
||||||
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return 0, merrors.Internal("decimals decode failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) {
|
||||||
|
tokenAddr := common.HexToAddress(token)
|
||||||
|
walletAddr := common.HexToAddress(wallet)
|
||||||
|
addr := strings.TrimPrefix(walletAddr.Hex(), "0x")
|
||||||
|
if len(addr) < 64 {
|
||||||
|
addr = strings.Repeat("0", 64-len(addr)) + addr
|
||||||
|
}
|
||||||
|
call := map[string]string{
|
||||||
|
"to": strings.ToLower(tokenAddr.Hex()),
|
||||||
|
"data": "0x70a08231" + addr,
|
||||||
|
}
|
||||||
|
var hexResp string
|
||||||
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
|
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
||||||
|
}
|
||||||
|
bigVal, err := shared.DecodeHexBig(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("balanceOf decode failed: " + err.Error())
|
||||||
|
}
|
||||||
|
return bigVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||||
|
call := map[string]string{
|
||||||
|
"to": strings.ToLower(token.Hex()),
|
||||||
|
"data": "0x313ce567",
|
||||||
|
}
|
||||||
|
var hexResp string
|
||||||
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
|
return 0, executorInternal("decimals call failed", err)
|
||||||
|
}
|
||||||
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return 0, executorInternal("decimals decode failed", err)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type gasEstimator interface {
|
||||||
|
EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||||
|
if isTronNetwork(network) {
|
||||||
|
if rpcClient == nil {
|
||||||
|
return 0, merrors.Internal("rpc client not initialised")
|
||||||
|
}
|
||||||
|
return estimateGasTron(ctx, rpcClient, callMsg)
|
||||||
|
}
|
||||||
|
return client.EstimateGas(ctx, callMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||||
|
call := tronEstimateCall(callMsg)
|
||||||
|
var hexResp string
|
||||||
|
if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
val, err := shared.DecodeHexBig(hexResp)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
return 0, merrors.Internal("failed to decode gas estimate")
|
||||||
|
}
|
||||||
|
return val.Uint64(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
|
||||||
|
call := make(map[string]string)
|
||||||
|
if callMsg.From != (common.Address{}) {
|
||||||
|
call["from"] = strings.ToLower(callMsg.From.Hex())
|
||||||
|
}
|
||||||
|
if callMsg.To != nil {
|
||||||
|
call["to"] = strings.ToLower(callMsg.To.Hex())
|
||||||
|
}
|
||||||
|
if callMsg.Gas > 0 {
|
||||||
|
call["gas"] = hexutil.EncodeUint64(callMsg.Gas)
|
||||||
|
}
|
||||||
|
if callMsg.GasPrice != nil {
|
||||||
|
call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice)
|
||||||
|
}
|
||||||
|
if callMsg.Value != nil {
|
||||||
|
call["value"] = hexutil.EncodeBig(callMsg.Value)
|
||||||
|
}
|
||||||
|
if len(callMsg.Data) > 0 {
|
||||||
|
call["data"] = hexutil.Encode(callMsg.Data)
|
||||||
|
}
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTronNetwork(network shared.Network) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(network.Name)), "tron")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
||||||
|
}
|
||||||
|
if value.IsNegative() {
|
||||||
|
return nil, merrors.InvalidArgument("amount must be positive")
|
||||||
|
}
|
||||||
|
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
||||||
|
scaled := value.Mul(multiplier)
|
||||||
|
if !scaled.Equal(scaled.Truncate(0)) {
|
||||||
|
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
||||||
|
}
|
||||||
|
return scaled.BigInt(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executorInvalid(msg string) error {
|
||||||
|
return merrors.InvalidArgument("executor: " + msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executorInternal(msg string, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
msg = msg + ": " + err.Error()
|
||||||
|
}
|
||||||
|
return merrors.Internal("executor: " + msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000)
|
||||||
|
|
||||||
|
// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount.
|
||||||
|
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, false, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, false, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, false, merrors.InvalidArgument("current native balance is required")
|
||||||
|
}
|
||||||
|
if network.GasTopUpPolicy == nil {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||||
|
if nativeCurrency == "" {
|
||||||
|
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||||
|
return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||||
|
return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatedNative, err := evmToNative(estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
currentNative, err := evmToNative(currentBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||||
|
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||||
|
}
|
||||||
|
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := estimatedNative.Sub(currentNative)
|
||||||
|
if required.IsNegative() {
|
||||||
|
required = decimal.Zero
|
||||||
|
}
|
||||||
|
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||||
|
|
||||||
|
minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative)
|
||||||
|
if minBalanceTopUp.IsNegative() {
|
||||||
|
minBalanceTopUp = decimal.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTopUp := bufferedRequired
|
||||||
|
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||||
|
rawTopUp = minBalanceTopUp
|
||||||
|
}
|
||||||
|
|
||||||
|
roundedTopUp := decimal.Zero
|
||||||
|
if rawTopUp.IsPositive() {
|
||||||
|
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := roundedTopUp
|
||||||
|
capHit := false
|
||||||
|
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||||
|
topUp = rule.MaxTopUp
|
||||||
|
capHit = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !topUp.IsPositive() {
|
||||||
|
return nil, capHit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(nativeCurrency),
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}, capHit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return value.Div(evmBaseUnitFactor), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package evm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "46000000000000000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "ETH", topUp.GetCurrency())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "19000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := ethNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "2000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := ethNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.True(t, capHit)
|
||||||
|
require.Equal(t, "10000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||||
|
network := ethNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.1),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(10),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
Contract: &shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.5),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(5),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := ethNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||||
|
|
||||||
|
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.False(t, capHit)
|
||||||
|
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||||
|
return &shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.15),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(20),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(500),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||||
|
return shared.Network{
|
||||||
|
Name: "ethereum_mainnet",
|
||||||
|
NativeToken: "ETH",
|
||||||
|
GasTopUpPolicy: policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ethMoney(eth string) *moneyv1.Money {
|
||||||
|
value, _ := decimal.NewFromString(eth)
|
||||||
|
baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: "ETH",
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tronHexPrefix = "0x"
|
||||||
|
|
||||||
|
var base58Alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
|
||||||
|
|
||||||
|
func normalizeAddress(address string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
|
||||||
|
return hexToBase58(trimmed)
|
||||||
|
}
|
||||||
|
decoded, err := base58Decode(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := validateChecksum(decoded); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base58Encode(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcAddress(address string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
|
||||||
|
return normalizeHexRPC(trimmed)
|
||||||
|
}
|
||||||
|
return base58ToHex(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexToBase58(address string) (string, error) {
|
||||||
|
bytesAddr, err := parseHexAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload := append(bytesAddr, checksum(bytesAddr)...)
|
||||||
|
return base58Encode(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func base58ToHex(address string) (string, error) {
|
||||||
|
decoded, err := base58Decode(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := validateChecksum(decoded); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexAddress(address string) ([]byte, error) {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(address), tronHexPrefix)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, merrors.InvalidArgument("address is required")
|
||||||
|
}
|
||||||
|
if len(trimmed)%2 == 1 {
|
||||||
|
trimmed = "0" + trimmed
|
||||||
|
}
|
||||||
|
decoded, err := hex.DecodeString(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid hex address")
|
||||||
|
}
|
||||||
|
switch len(decoded) {
|
||||||
|
case 20:
|
||||||
|
prefixed := make([]byte, 21)
|
||||||
|
prefixed[0] = 0x41
|
||||||
|
copy(prefixed[1:], decoded)
|
||||||
|
return prefixed, nil
|
||||||
|
case 21:
|
||||||
|
if decoded[0] != 0x41 {
|
||||||
|
return nil, merrors.InvalidArgument("invalid tron address prefix")
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid tron address length %d", len(decoded)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHexRPC(address string) (string, error) {
|
||||||
|
decoded, err := parseHexAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChecksum(decoded []byte) error {
|
||||||
|
if len(decoded) != 25 {
|
||||||
|
return merrors.InvalidArgument("invalid tron address length")
|
||||||
|
}
|
||||||
|
payload := decoded[:21]
|
||||||
|
expected := checksum(payload)
|
||||||
|
if !bytes.Equal(expected, decoded[21:]) {
|
||||||
|
return merrors.InvalidArgument("invalid tron address checksum")
|
||||||
|
}
|
||||||
|
if payload[0] != 0x41 {
|
||||||
|
return merrors.InvalidArgument("invalid tron address prefix")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checksum(payload []byte) []byte {
|
||||||
|
first := sha256.Sum256(payload)
|
||||||
|
second := sha256.Sum256(first[:])
|
||||||
|
return second[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
func base58Encode(input []byte) string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
x := new(big.Int).SetBytes(input)
|
||||||
|
base := big.NewInt(58)
|
||||||
|
zero := big.NewInt(0)
|
||||||
|
mod := new(big.Int)
|
||||||
|
|
||||||
|
encoded := make([]byte, 0, len(input))
|
||||||
|
for x.Cmp(zero) > 0 {
|
||||||
|
x.DivMod(x, base, mod)
|
||||||
|
encoded = append(encoded, base58Alphabet[mod.Int64()])
|
||||||
|
}
|
||||||
|
for _, b := range input {
|
||||||
|
if b != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
encoded = append(encoded, base58Alphabet[0])
|
||||||
|
}
|
||||||
|
reverse(encoded)
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func base58Decode(input string) ([]byte, error) {
|
||||||
|
result := big.NewInt(0)
|
||||||
|
base := big.NewInt(58)
|
||||||
|
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
idx := bytes.IndexByte(base58Alphabet, input[i])
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("invalid base58 address")
|
||||||
|
}
|
||||||
|
result.Mul(result, base)
|
||||||
|
result.Add(result, big.NewInt(int64(idx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := result.Bytes()
|
||||||
|
zeroCount := 0
|
||||||
|
for zeroCount < len(input) && input[zeroCount] == base58Alphabet[0] {
|
||||||
|
zeroCount++
|
||||||
|
}
|
||||||
|
if zeroCount > 0 {
|
||||||
|
decoded = append(make([]byte, zeroCount), decoded...)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverse(data []byte) {
|
||||||
|
for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
data[i], data[j] = data[j], data[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexString(value string) bool {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(value), tronHexPrefix)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
case r >= 'A' && r <= 'F':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
245
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
245
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Tron-specific behavior, including address conversion.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("tron")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "tron"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Format address", zap.String("address", address))
|
||||||
|
normalized, err := normalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||||
|
normalized, err := normalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||||
|
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Balance address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("address", wallet.DepositAddress),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Balance failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||||
|
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Native balance address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("address", wallet.DepositAddress),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Native balance failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if amount == nil {
|
||||||
|
return nil, merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
rpcFrom, err := rpcAddress(wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("address", wallet.DepositAddress),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpcTo, err := rpcAddress(destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee destination conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rpcFrom == rpcTo {
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: "0",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Estimate fee failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("from_address", wallet.DepositAddress),
|
||||||
|
zap.String("from_rpc", rpcFrom),
|
||||||
|
zap.String("to_address", destination),
|
||||||
|
zap.String("to_rpc", rpcTo),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("Estimate fee result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
if source == nil {
|
||||||
|
return "", merrors.InvalidArgument("source wallet is required")
|
||||||
|
}
|
||||||
|
d.logger.Debug("Submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
rpcFrom, err := rpcAddress(source.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer address conversion failed", zap.Error(err),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
zap.String("address", source.DepositAddress),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcTo, err := rpcAddress(destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer destination conversion failed", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Submit transfer failed", zap.Error(err),
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("Submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("Awaiting confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("Awaiting of confirmation failed", zap.Error(err),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("Await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeCurrency(network shared.Network) string {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
|
||||||
|
logger := zap.NewNop()
|
||||||
|
d := New(logger)
|
||||||
|
wallet := &model.ManagedWallet{
|
||||||
|
WalletRef: "wallet_ref",
|
||||||
|
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
|
||||||
|
}
|
||||||
|
network := shared.Network{
|
||||||
|
Name: "tron_mainnet",
|
||||||
|
NativeToken: "TRX",
|
||||||
|
}
|
||||||
|
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
|
||||||
|
|
||||||
|
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, fee)
|
||||||
|
require.Equal(t, "TRX", fee.GetCurrency())
|
||||||
|
require.Equal(t, "0", fee.GetAmount())
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
|
||||||
|
|
||||||
|
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
|
||||||
|
type GasTopUpDecision struct {
|
||||||
|
CurrentBalanceTRX decimal.Decimal
|
||||||
|
EstimatedFeeTRX decimal.Decimal
|
||||||
|
RequiredTRX decimal.Decimal
|
||||||
|
BufferedRequiredTRX decimal.Decimal
|
||||||
|
MinBalanceTopUpTRX decimal.Decimal
|
||||||
|
RawTopUpTRX decimal.Decimal
|
||||||
|
RoundedTopUpTRX decimal.Decimal
|
||||||
|
TopUpTRX decimal.Decimal
|
||||||
|
CapHit bool
|
||||||
|
OperationType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
|
||||||
|
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
|
||||||
|
decision := GasTopUpDecision{}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, decision, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, decision, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, decision, merrors.InvalidArgument("current native balance is required")
|
||||||
|
}
|
||||||
|
if network.GasTopUpPolicy == nil {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||||
|
if nativeCurrency == "" {
|
||||||
|
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||||
|
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||||
|
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatedTRX, err := tronToTRX(estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decision, err
|
||||||
|
}
|
||||||
|
currentTRX, err := tronToTRX(currentBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decision, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||||
|
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||||
|
if !ok {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||||
|
}
|
||||||
|
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := estimatedTRX.Sub(currentTRX)
|
||||||
|
if required.IsNegative() {
|
||||||
|
required = decimal.Zero
|
||||||
|
}
|
||||||
|
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||||
|
|
||||||
|
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
|
||||||
|
if minBalanceTopUp.IsNegative() {
|
||||||
|
minBalanceTopUp = decimal.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTopUp := bufferedRequired
|
||||||
|
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||||
|
rawTopUp = minBalanceTopUp
|
||||||
|
}
|
||||||
|
|
||||||
|
roundedTopUp := decimal.Zero
|
||||||
|
if rawTopUp.IsPositive() {
|
||||||
|
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := roundedTopUp
|
||||||
|
capHit := false
|
||||||
|
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||||
|
topUp = rule.MaxTopUp
|
||||||
|
capHit = true
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = GasTopUpDecision{
|
||||||
|
CurrentBalanceTRX: currentTRX,
|
||||||
|
EstimatedFeeTRX: estimatedTRX,
|
||||||
|
RequiredTRX: required,
|
||||||
|
BufferedRequiredTRX: bufferedRequired,
|
||||||
|
MinBalanceTopUpTRX: minBalanceTopUp,
|
||||||
|
RawTopUpTRX: rawTopUp,
|
||||||
|
RoundedTopUpTRX: roundedTopUp,
|
||||||
|
TopUpTRX: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
OperationType: operationType(isContract),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !topUp.IsPositive() {
|
||||||
|
return nil, decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(nativeCurrency),
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}, decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return value.Div(tronBaseUnitFactor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationType(contract bool) string {
|
||||||
|
if contract {
|
||||||
|
return "trc20"
|
||||||
|
}
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, topUp)
|
||||||
|
require.True(t, decision.TopUpTRX.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "46000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "TRX", topUp.GetCurrency())
|
||||||
|
require.Equal(t, "46", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "19000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "19", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "2000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "2", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "10000000", topUp.GetAmount())
|
||||||
|
require.True(t, decision.CapHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "15000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.1),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(10),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
Contract: &shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.5),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(5),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "15000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||||
|
require.Equal(t, "trc20", decision.OperationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||||
|
return &shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.15),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(20),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(500),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||||
|
return shared.Network{
|
||||||
|
Name: "tron_mainnet",
|
||||||
|
NativeToken: "TRX",
|
||||||
|
GasTopUpPolicy: policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronMoney(trx string) *moneyv1.Money {
|
||||||
|
value, _ := decimal.NewFromString(trx)
|
||||||
|
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: "TRX",
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/ethereum"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry maps configured network keys to chain drivers.
|
||||||
|
type Registry struct {
|
||||||
|
byNetwork map[string]driver.Driver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry selects drivers for the configured networks.
|
||||||
|
func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, merrors.InvalidArgument("driver registry: logger is required")
|
||||||
|
}
|
||||||
|
result := &Registry{byNetwork: map[string]driver.Driver{}}
|
||||||
|
for _, network := range networks {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chainDriver, err := resolveDriver(logger, name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.byNetwork[name] = chainDriver
|
||||||
|
}
|
||||||
|
if len(result.byNetwork) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
|
||||||
|
}
|
||||||
|
logger.Info("Chain drivers configured", zap.Int("count", len(result.byNetwork)))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver resolves a driver for the provided network key.
|
||||||
|
func (r *Registry) Driver(network string) (driver.Driver, error) {
|
||||||
|
if r == nil || len(r.byNetwork) == 0 {
|
||||||
|
return nil, merrors.Internal("driver registry is not configured")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if key == "" {
|
||||||
|
return nil, merrors.InvalidArgument("network is required")
|
||||||
|
}
|
||||||
|
chainDriver, ok := r.byNetwork[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
|
||||||
|
}
|
||||||
|
return chainDriver, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(network, "tron"):
|
||||||
|
return tron.New(logger), nil
|
||||||
|
case strings.HasPrefix(network, "arbitrum"):
|
||||||
|
return arbitrum.New(logger), nil
|
||||||
|
case strings.HasPrefix(network, "ethereum"):
|
||||||
|
return ethereum.New(logger), nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument("unsupported chain network " + network)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ type TransferExecutor interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
|
||||||
return &onChainExecutor{
|
return &onChainExecutor{
|
||||||
logger: logger.Named("executor"),
|
logger: logger.Named("executor"),
|
||||||
keyManager: keyManager,
|
keyManager: keyManager,
|
||||||
clients: map[string]*ethclient.Client{},
|
clients: clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,49 +42,52 @@ type onChainExecutor struct {
|
|||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
|
|
||||||
mu sync.Mutex
|
clients *rpcclient.Clients
|
||||||
clients map[string]*ethclient.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||||
if o.keyManager == nil {
|
if o.keyManager == nil {
|
||||||
o.logger.Error("key manager not configured")
|
o.logger.Warn("Key manager not configured")
|
||||||
return "", executorInternal("key manager is not configured", nil)
|
return "", executorInternal("key manager is not configured", nil)
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
|
o.logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
||||||
return "", executorInvalid("network rpc url is not configured")
|
return "", executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
if source == nil || transfer == nil {
|
if source == nil || transfer == nil {
|
||||||
o.logger.Error("transfer context missing")
|
o.logger.Warn("Transfer context missing")
|
||||||
return "", executorInvalid("transfer context missing")
|
return "", executorInvalid("transfer context missing")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(source.KeyReference) == "" {
|
if strings.TrimSpace(source.KeyReference) == "" {
|
||||||
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
o.logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||||
return "", executorInvalid("source wallet missing key reference")
|
return "", executorInvalid("source wallet missing key reference")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(source.DepositAddress) == "" {
|
if strings.TrimSpace(source.DepositAddress) == "" {
|
||||||
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
o.logger.Warn("Source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||||
return "", executorInvalid("source wallet missing deposit address")
|
return "", executorInvalid("source wallet missing deposit address")
|
||||||
}
|
}
|
||||||
if !common.IsHexAddress(destinationAddress) {
|
if !common.IsHexAddress(destinationAddress) {
|
||||||
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
o.logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||||
return "", executorInvalid("invalid destination address " + destinationAddress)
|
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
o.logger.Info("submitting transfer",
|
o.logger.Info("Submitting transfer",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("source_wallet_ref", source.WalletRef),
|
zap.String("source_wallet_ref", source.WalletRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("destination", strings.ToLower(destinationAddress)),
|
zap.String("destination", strings.ToLower(destinationAddress)),
|
||||||
)
|
)
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to initialise rpc client",
|
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("Failed to initialise RPC client",
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("rpc_url", rpcURL),
|
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
@@ -98,17 +101,16 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to fetch nonce",
|
o.logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to fetch nonce", err)
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gasPrice, err := client.SuggestGasPrice(ctx)
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to suggest gas price",
|
o.logger.Warn("Failed to suggest gas price",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -122,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||||
|
|
||||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||||
o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
o.logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !common.IsHexAddress(transfer.ContractAddress) {
|
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||||
o.logger.Warn("invalid token contract address",
|
o.logger.Warn("Invalid token contract address",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
zap.String("contract", transfer.ContractAddress),
|
||||||
)
|
)
|
||||||
@@ -135,34 +137,32 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||||
|
|
||||||
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to read token decimals",
|
o.logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
zap.String("contract", transfer.ContractAddress),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
amount := transfer.NetAmount
|
amount := transfer.NetAmount
|
||||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||||
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
o.logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
return "", executorInvalid("transfer missing net amount")
|
return "", executorInvalid("transfer missing net amount")
|
||||||
}
|
}
|
||||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to convert amount to base units",
|
o.logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("amount", amount.Amount),
|
zap.String("amount", amount.Amount),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
input, err := erc20ABI.Pack("transfer", destination, amountInt)
|
input, err := erc20ABI.Pack("transfer", destination, amountInt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to encode transfer call",
|
o.logger.Warn("Failed to encode transfer call",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
@@ -177,7 +177,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to estimate gas",
|
o.logger.Warn("Failed to estimate gas",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
@@ -188,24 +188,22 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to sign transaction",
|
o.logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
o.logger.Warn("failed to send transaction",
|
o.logger.Warn("Failed to send transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to send transaction", err)
|
return "", executorInternal("failed to send transaction", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
txHash = signedTx.Hash().Hex()
|
txHash = signedTx.Hash().Hex()
|
||||||
o.logger.Info("transaction submitted",
|
o.logger.Info("Transaction submitted",
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
@@ -214,42 +212,18 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
return txHash, nil
|
return txHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
|
|
||||||
o.mu.Lock()
|
|
||||||
client, ok := o.clients[rpcURL]
|
|
||||||
o.mu.Unlock()
|
|
||||||
if ok {
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
o.mu.Lock()
|
|
||||||
defer o.mu.Unlock()
|
|
||||||
if existing, ok := o.clients[rpcURL]; ok {
|
|
||||||
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
|
|
||||||
c.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
o.clients[rpcURL] = c
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
if strings.TrimSpace(txHash) == "" {
|
if strings.TrimSpace(txHash) == "" {
|
||||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||||
return nil, executorInvalid("tx hash is required")
|
return nil, executorInvalid("tx hash is required")
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||||
return nil, executorInvalid("network rpc url is not configured")
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -264,27 +238,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
|||||||
if errors.Is(err, ethereum.NotFound) {
|
if errors.Is(err, ethereum.NotFound) {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
o.logger.Debug("transaction not yet mined",
|
o.logger.Debug("Transaction not yet mined",
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
o.logger.Warn("context cancelled while awaiting confirmation",
|
o.logger.Warn("Context cancelled while awaiting confirmation",
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
)
|
)
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
o.logger.Warn("failed to fetch transaction receipt",
|
o.logger.Warn("Failed to fetch transaction receipt",
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return nil, executorInternal("failed to fetch transaction receipt", err)
|
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||||
}
|
}
|
||||||
o.logger.Info("transaction confirmed",
|
o.logger.Info("Transaction confirmed",
|
||||||
zap.String("tx_hash", txHash),
|
zap.String("tx_hash", txHash),
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
|
|||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||||
callData, err := erc20ABI.Pack("decimals")
|
call := map[string]string{
|
||||||
if err != nil {
|
"to": strings.ToLower(token.Hex()),
|
||||||
return 0, executorInternal("failed to encode decimals call", err)
|
"data": "0x313ce567",
|
||||||
}
|
}
|
||||||
msg := ethereum.CallMsg{
|
var hexResp string
|
||||||
To: &token,
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, executorInternal("decimals call failed", err)
|
return 0, executorInternal("decimals call failed", err)
|
||||||
}
|
}
|
||||||
values, err := erc20ABI.Unpack("decimals", output)
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, executorInternal("failed to unpack decimals", err)
|
return 0, executorInternal("decimals decode failed", err)
|
||||||
}
|
}
|
||||||
if len(values) == 0 {
|
return val, nil
|
||||||
return 0, executorInternal("decimals call returned no data", nil)
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, executorInternal("decimals call returned unexpected type", nil)
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -18,10 +20,10 @@ func WithKeyManager(manager keymanager.Manager) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
// WithRPCClients configures pre-initialised RPC clients.
|
||||||
func WithTransferExecutor(executor TransferExecutor) Option {
|
func WithRPCClients(clients *rpcclient.Clients) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.executor = executor
|
s.rpcClients = clients
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDriverRegistry configures the chain driver registry.
|
||||||
|
func WithDriverRegistry(registry *drivers.Registry) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.drivers = registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithClock overrides the service clock.
|
// WithClock overrides the service clock.
|
||||||
func WithClock(clk clockpkg.Clock) Option {
|
func WithClock(clk clockpkg.Clock) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
204
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
204
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package rpcclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clients holds pre-initialised RPC clients keyed by network name.
|
||||||
|
type Clients struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
clients map[string]clientEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientEntry struct {
|
||||||
|
eth *ethclient.Client
|
||||||
|
rpc *rpc.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare dials all configured networks up front and returns a ready-to-use client set.
|
||||||
|
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients: logger is required")
|
||||||
|
}
|
||||||
|
clientLogger := logger.Named("rpc_client")
|
||||||
|
result := &Clients{
|
||||||
|
logger: clientLogger,
|
||||||
|
clients: make(map[string]clientEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, network := range networks {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if name == "" {
|
||||||
|
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rpcURL == "" {
|
||||||
|
result.Close()
|
||||||
|
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
|
||||||
|
clientLogger.Warn("Rpc url missing", zap.String("network", name))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("network", name),
|
||||||
|
}
|
||||||
|
clientLogger.Info("Initialising rpc client", fields...)
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &loggingRoundTripper{
|
||||||
|
logger: clientLogger,
|
||||||
|
network: name,
|
||||||
|
endpoint: rpcURL,
|
||||||
|
base: http.DefaultTransport,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
result.Close()
|
||||||
|
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
|
||||||
|
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
||||||
|
}
|
||||||
|
client := ethclient.NewClient(rpcCli)
|
||||||
|
result.clients[name] = clientEntry{
|
||||||
|
eth: client,
|
||||||
|
rpc: rpcCli,
|
||||||
|
}
|
||||||
|
clientLogger.Info("RPC client ready", fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.clients) == 0 {
|
||||||
|
clientLogger.Warn("No rpc clients were initialised")
|
||||||
|
return nil, merrors.InvalidArgument("no rpc clients initialised")
|
||||||
|
} else {
|
||||||
|
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client returns a prepared client for the given network name.
|
||||||
|
func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, merrors.Internal("RPC clients not initialised")
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
entry, ok := c.clients[name]
|
||||||
|
if !ok || entry.eth == nil {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
|
||||||
|
}
|
||||||
|
return entry.eth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCClient returns the raw RPC client for low-level calls.
|
||||||
|
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
entry, ok := c.clients[name]
|
||||||
|
if !ok || entry.rpc == nil {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
||||||
|
}
|
||||||
|
return entry.rpc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tears down all RPC clients, logging each close.
|
||||||
|
func (c *Clients) Close() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for name, entry := range c.clients {
|
||||||
|
if entry.rpc != nil {
|
||||||
|
entry.rpc.Close()
|
||||||
|
} else if entry.eth != nil {
|
||||||
|
entry.eth.Close()
|
||||||
|
}
|
||||||
|
if c.logger != nil {
|
||||||
|
c.logger.Info("RPC client closed", zap.String("network", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loggingRoundTripper struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
network string
|
||||||
|
endpoint string
|
||||||
|
base http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if l.base == nil {
|
||||||
|
l.base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody []byte
|
||||||
|
if req.Body != nil {
|
||||||
|
raw, _ := io.ReadAll(req.Body)
|
||||||
|
reqBody = raw
|
||||||
|
req.Body = io.NopCloser(strings.NewReader(string(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("network", l.network),
|
||||||
|
}
|
||||||
|
if len(reqBody) > 0 {
|
||||||
|
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
|
||||||
|
}
|
||||||
|
l.logger.Debug("RPC request", fields...)
|
||||||
|
|
||||||
|
resp, err := l.base.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
|
||||||
|
|
||||||
|
respFields := append(fields,
|
||||||
|
zap.Int("status_code", resp.StatusCode),
|
||||||
|
)
|
||||||
|
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
|
||||||
|
respFields = append(respFields, zap.String("content_type", contentType))
|
||||||
|
}
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||||
|
}
|
||||||
|
l.logger.Debug("RPC response", respFields...)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
l.logger.Warn("RPC response error", respFields...)
|
||||||
|
} else if len(bodyBytes) == 0 {
|
||||||
|
l.logger.Warn("RPC response empty body", respFields...)
|
||||||
|
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
|
||||||
|
l.logger.Warn("RPC response invalid JSON", respFields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if max <= 3 {
|
||||||
|
return s[:max]
|
||||||
|
}
|
||||||
|
return s[:max-3] + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package rpcclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry binds static network metadata with prepared RPC clients.
|
||||||
|
type Registry struct {
|
||||||
|
networks map[string]shared.Network
|
||||||
|
clients *Clients
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry constructs a registry keyed by lower-cased network name.
|
||||||
|
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
|
||||||
|
return &Registry{
|
||||||
|
networks: networks,
|
||||||
|
clients: clients,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network fetches network metadata by key (case-insensitive).
|
||||||
|
func (r *Registry) Network(key string) (shared.Network, bool) {
|
||||||
|
if r == nil || len(r.networks) == 0 {
|
||||||
|
return shared.Network{}, false
|
||||||
|
}
|
||||||
|
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
|
||||||
|
return n, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client returns the prepared RPC client for the given network name.
|
||||||
|
func (r *Registry) Client(key string) (*ethclient.Client, error) {
|
||||||
|
if r == nil || r.clients == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCClient returns the raw RPC client for low-level calls.
|
||||||
|
func (r *Registry) RPCClient(key string) (*rpc.Client, error) {
|
||||||
|
if r == nil || r.clients == nil {
|
||||||
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
|
}
|
||||||
|
return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Networks exposes the registry map for iteration when needed.
|
||||||
|
func (r *Registry) Networks() map[string]shared.Network {
|
||||||
|
return r.networks
|
||||||
|
}
|
||||||
@@ -3,19 +3,24 @@ package gateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/appversion"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +34,7 @@ var (
|
|||||||
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
|
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the ChainGatewayService RPC contract.
|
// Service implements the UnifiedGatewayService RPC contract for chain operations.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
@@ -38,13 +43,16 @@ type Service struct {
|
|||||||
|
|
||||||
settings CacheSettings
|
settings CacheSettings
|
||||||
|
|
||||||
networks map[string]shared.Network
|
networks map[string]shared.Network
|
||||||
serviceWallet shared.ServiceWallet
|
serviceWallet shared.ServiceWallet
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
executor TransferExecutor
|
rpcClients *rpcclient.Clients
|
||||||
commands commands.Registry
|
networkRegistry *rpcclient.Registry
|
||||||
|
drivers *drivers.Registry
|
||||||
|
commands commands.Registry
|
||||||
|
announcers []*discovery.Announcer
|
||||||
|
|
||||||
chainv1.UnimplementedChainGatewayServiceServer
|
unifiedv1.UnimplementedUnifiedGatewayServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs the chain gateway service skeleton.
|
// NewService constructs the chain gateway service skeleton.
|
||||||
@@ -73,11 +81,13 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.networks = map[string]shared.Network{}
|
svc.networks = map[string]shared.Network{}
|
||||||
}
|
}
|
||||||
svc.settings = svc.settings.withDefaults()
|
svc.settings = svc.settings.withDefaults()
|
||||||
|
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
|
||||||
|
|
||||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
Transfer: commandsTransferDeps(svc),
|
Transfer: commandsTransferDeps(svc),
|
||||||
})
|
})
|
||||||
|
svc.startDiscoveryAnnouncers()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
@@ -85,10 +95,21 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
// Register wires the service onto the provided gRPC router.
|
// Register wires the service onto the provided gRPC router.
|
||||||
func (s *Service) Register(router routers.GRPC) error {
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
chainv1.RegisterChainGatewayServiceServer(reg, s)
|
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, announcer := range s.announcers {
|
||||||
|
if announcer != nil {
|
||||||
|
announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
||||||
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
|
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
|
||||||
}
|
}
|
||||||
@@ -121,6 +142,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate
|
|||||||
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||||
if s.storage == nil {
|
if s.storage == nil {
|
||||||
return errStorageUnavailable
|
return errStorageUnavailable
|
||||||
@@ -131,11 +160,13 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
|||||||
func commandsWalletDeps(s *Service) wallet.Deps {
|
func commandsWalletDeps(s *Service) wallet.Deps {
|
||||||
return wallet.Deps{
|
return wallet.Deps{
|
||||||
Logger: s.logger.Named("command"),
|
Logger: s.logger.Named("command"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
KeyManager: s.keyManager,
|
KeyManager: s.keyManager,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +174,11 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
|||||||
func commandsTransferDeps(s *Service) transfer.Deps {
|
func commandsTransferDeps(s *Service) transfer.Deps {
|
||||||
return transfer.Deps{
|
return transfer.Deps{
|
||||||
Logger: s.logger.Named("transfer_cmd"),
|
Logger: s.logger.Named("transfer_cmd"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
LaunchExecution: s.launchTransferExecution,
|
LaunchExecution: s.launchTransferExecution,
|
||||||
}
|
}
|
||||||
@@ -157,3 +190,30 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
|
|||||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncers() {
|
||||||
|
if s == nil || s.producer == nil || len(s.networks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := appversion.Create().Short()
|
||||||
|
for _, network := range s.networks {
|
||||||
|
currencies := []string{shared.NativeCurrency(network)}
|
||||||
|
for _, token := range network.TokenConfigs {
|
||||||
|
if token.Symbol != "" {
|
||||||
|
currencies = append(currencies, token.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "CRYPTO_RAIL_GATEWAY",
|
||||||
|
Rail: "CRYPTO",
|
||||||
|
Network: network.Name,
|
||||||
|
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"},
|
||||||
|
Currencies: currencies,
|
||||||
|
InvokeURI: discovery.DefaultInvokeURI(string(mservice.ChainGateway)),
|
||||||
|
Version: version,
|
||||||
|
}
|
||||||
|
announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce)
|
||||||
|
announcer.Start()
|
||||||
|
s.announcers = append(s.announcers, announcer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
@@ -65,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
|||||||
require.Equal(t, 1, repo.wallets.count())
|
require.Equal(t, 1, repo.wallets.count())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-native",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-1",
|
||||||
|
Asset: &ichainv1.Asset{
|
||||||
|
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "ETH",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.GetWallet())
|
||||||
|
require.Equal(t, "ETH", resp.GetWallet().GetAsset().GetTokenSymbol())
|
||||||
|
require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress())
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
||||||
svc, repo := newTestService(t)
|
svc, repo := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -143,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
|
|||||||
require.Equal(t, codes.NotFound, st.Code())
|
require.Equal(t, codes.NotFound, st.Code())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) {
|
||||||
|
svc, repo := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-balance",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-1",
|
||||||
|
Asset: &ichainv1.Asset{
|
||||||
|
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "USDC",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
walletRef := createResp.GetWallet().GetWalletRef()
|
||||||
|
|
||||||
|
err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{
|
||||||
|
WalletRef: walletRef,
|
||||||
|
Available: &moneyv1.Money{Currency: "USDC", Amount: "25"},
|
||||||
|
NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.5"},
|
||||||
|
CalculatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.GetBalance())
|
||||||
|
require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount())
|
||||||
|
require.Equal(t, "ETH", resp.GetBalance().GetNativeAvailable().GetCurrency())
|
||||||
|
}
|
||||||
|
|
||||||
// ---- in-memory storage implementation ----
|
// ---- in-memory storage implementation ----
|
||||||
|
|
||||||
type inMemoryRepository struct {
|
type inMemoryRepository struct {
|
||||||
@@ -526,18 +577,23 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
|
|||||||
return int64(requested)
|
return int64(requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
||||||
repo := newInMemoryRepository()
|
repo := newInMemoryRepository()
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
networks := []shared.Network{{
|
||||||
|
Name: "ethereum_mainnet",
|
||||||
|
NativeToken: "ETH",
|
||||||
|
TokenConfigs: []shared.TokenContract{
|
||||||
|
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
|
||||||
|
require.NoError(t, err)
|
||||||
svc := NewService(logger, repo, nil,
|
svc := NewService(logger, repo, nil,
|
||||||
WithKeyManager(&fakeKeyManager{}),
|
WithKeyManager(&fakeKeyManager{}),
|
||||||
WithNetworks([]shared.Network{{
|
WithNetworks(networks),
|
||||||
Name: "ethereum_mainnet",
|
|
||||||
TokenConfigs: []shared.TokenContract{
|
|
||||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
|
||||||
},
|
|
||||||
}}),
|
|
||||||
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||||
|
WithDriverRegistry(driverRegistry),
|
||||||
)
|
)
|
||||||
return svc, repo
|
return svc, repo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package gateway
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||||
|
const defaultRPCRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
// CacheSettings holds tunable gateway behaviour.
|
// CacheSettings holds tunable gateway behaviour.
|
||||||
type CacheSettings struct {
|
type CacheSettings struct {
|
||||||
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||||
|
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultSettings() CacheSettings {
|
func defaultSettings() CacheSettings {
|
||||||
return CacheSettings{
|
return CacheSettings{
|
||||||
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||||
|
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ func (s CacheSettings) withDefaults() CacheSettings {
|
|||||||
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||||
}
|
}
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,3 +34,10 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) rpcTimeout() time.Duration {
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
return defaultRPCRequestTimeout
|
||||||
|
}
|
||||||
|
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "github.com/shopspring/decimal"
|
||||||
|
|
||||||
|
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
|
||||||
|
type GasTopUpRule struct {
|
||||||
|
BufferPercent decimal.Decimal
|
||||||
|
MinNativeBalance decimal.Decimal
|
||||||
|
RoundingUnit decimal.Decimal
|
||||||
|
MaxTopUp decimal.Decimal
|
||||||
|
}
|
||||||
|
|
||||||
|
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
|
||||||
|
type GasTopUpPolicy struct {
|
||||||
|
Default GasTopUpRule
|
||||||
|
Native *GasTopUpRule
|
||||||
|
Contract *GasTopUpRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule selects the policy rule for the transfer type.
|
||||||
|
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
|
||||||
|
if p == nil {
|
||||||
|
return GasTopUpRule{}, false
|
||||||
|
}
|
||||||
|
if contractTransfer && p.Contract != nil {
|
||||||
|
return *p.Contract, true
|
||||||
|
}
|
||||||
|
if !contractTransfer && p.Native != nil {
|
||||||
|
return *p.Native, true
|
||||||
|
}
|
||||||
|
return p.Default, true
|
||||||
|
}
|
||||||
@@ -119,13 +119,23 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NativeCurrency returns the canonical native token symbol for a network.
|
||||||
|
func NativeCurrency(network Network) string {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
|
||||||
// Network describes a supported blockchain network and known token contracts.
|
// Network describes a supported blockchain network and known token contracts.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
Name string
|
Name string
|
||||||
RPCURL string
|
RPCURL string
|
||||||
ChainID uint64
|
ChainID uint64
|
||||||
NativeToken string
|
NativeToken string
|
||||||
TokenConfigs []TokenContract
|
TokenConfigs []TokenContract
|
||||||
|
GasTopUpPolicy *GasTopUpPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||||
|
|||||||
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errHexEmpty = errors.New("hex value is empty")
|
||||||
|
errHexInvalid = errors.New("invalid hex number")
|
||||||
|
errHexOutOfRange = errors.New("hex number out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodeHexBig parses a hex string that may include leading zero digits.
|
||||||
|
func DecodeHexBig(input string) (*big.Int, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, errHexEmpty
|
||||||
|
}
|
||||||
|
noPrefix := strings.TrimPrefix(trimmed, "0x")
|
||||||
|
if noPrefix == "" {
|
||||||
|
return nil, errHexEmpty
|
||||||
|
}
|
||||||
|
value := strings.TrimLeft(noPrefix, "0")
|
||||||
|
if value == "" {
|
||||||
|
return big.NewInt(0), nil
|
||||||
|
}
|
||||||
|
val := new(big.Int)
|
||||||
|
if _, ok := val.SetString(value, 16); !ok {
|
||||||
|
return nil, errHexInvalid
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros.
|
||||||
|
func DecodeHexUint8(input string) (uint8, error) {
|
||||||
|
val, err := DecodeHexBig(input)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
return 0, errHexInvalid
|
||||||
|
}
|
||||||
|
if val.BitLen() > 8 {
|
||||||
|
return 0, errHexOutOfRange
|
||||||
|
}
|
||||||
|
return uint8(val.Uint64()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDecodeHexUint8_LeadingZeros(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const resp = "0x0000000000000000000000000000000000000000000000000000000000000006"
|
||||||
|
val, err := DecodeHexUint8(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeHexUint8 error: %v", err)
|
||||||
|
}
|
||||||
|
if val != 6 {
|
||||||
|
t.Fatalf("DecodeHexUint8 value = %d, want 6", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
||||||
if s.executor == nil {
|
if s.drivers == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
||||||
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
s.logger.Warn("Failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||||
}
|
}
|
||||||
}(transferRef, sourceWalletRef, network)
|
}(transferRef, sourceWalletRef, network)
|
||||||
}
|
}
|
||||||
@@ -41,49 +41,73 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
|
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
|
||||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
driverDeps := s.driverDeps()
|
||||||
|
chainDriver, err := s.driverForNetwork(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
|
||||||
|
s.logger.Info("Self transfer detected; skipping submission",
|
||||||
|
zap.String("transfer_ref", transferRef),
|
||||||
|
zap.String("wallet_ref", sourceWalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
|
||||||
|
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
|
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
|
||||||
s.logger.Warn("failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
|
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||||
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("Failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
|
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
|
||||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil {
|
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil {
|
||||||
s.logger.Warn("failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil {
|
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil {
|
||||||
s.logger.Warn("failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) {
|
func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,10 +116,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverDeps() driver.Deps {
|
||||||
|
return driver.Deps{
|
||||||
|
Logger: s.logger.Named("driver"),
|
||||||
|
Registry: s.networkRegistry,
|
||||||
|
KeyManager: s.keyManager,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
|
||||||
|
if s.drivers == nil {
|
||||||
|
return nil, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
return s.drivers.Driver(network)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ type ServiceFee struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TransferDestination struct {
|
type TransferDestination struct {
|
||||||
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
|
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
|
||||||
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
|
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
|
||||||
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
ExternalAddressOriginal string `bson:"externalAddressOriginal,omitempty" json:"externalAddressOriginal,omitempty"`
|
||||||
|
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer models an on-chain transfer orchestrated by the gateway.
|
// Transfer models an on-chain transfer orchestrated by the gateway.
|
||||||
@@ -85,7 +86,8 @@ func (t *Transfer) Normalize() {
|
|||||||
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
|
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
|
||||||
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
|
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
|
||||||
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
|
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
|
||||||
t.Destination.ExternalAddress = strings.TrimSpace(strings.ToLower(t.Destination.ExternalAddress))
|
t.Destination.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress)
|
||||||
|
t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal)
|
||||||
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
|
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
|
||||||
t.ClientReference = strings.TrimSpace(t.ClientReference)
|
t.ClientReference = strings.TrimSpace(t.ClientReference)
|
||||||
}
|
}
|
||||||
|
|||||||
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) {
|
||||||
|
address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||||
|
transfer := &Transfer{
|
||||||
|
IdempotencyKey: "idemp",
|
||||||
|
TransferRef: "ref",
|
||||||
|
OrganizationRef: "org",
|
||||||
|
SourceWalletRef: "wallet",
|
||||||
|
Network: "tron_mainnet",
|
||||||
|
TokenSymbol: "USDT",
|
||||||
|
Destination: TransferDestination{
|
||||||
|
ExternalAddress: address,
|
||||||
|
ExternalAddressOriginal: address,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer.Normalize()
|
||||||
|
|
||||||
|
if transfer.Destination.ExternalAddress != address {
|
||||||
|
t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress)
|
||||||
|
}
|
||||||
|
if transfer.Destination.ExternalAddressOriginal != address {
|
||||||
|
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) {
|
||||||
|
address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||||
|
transfer := &Transfer{
|
||||||
|
Destination: TransferDestination{
|
||||||
|
ExternalAddress: address,
|
||||||
|
ExternalAddressOriginal: address,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer.Normalize()
|
||||||
|
|
||||||
|
if transfer.Destination.ExternalAddress != strings.ToLower(address) {
|
||||||
|
t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress)
|
||||||
|
}
|
||||||
|
if transfer.Destination.ExternalAddressOriginal != address {
|
||||||
|
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,8 @@ const (
|
|||||||
|
|
||||||
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
||||||
type ManagedWallet struct {
|
type ManagedWallet struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
pkgmodel.Describable `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
@@ -45,6 +47,7 @@ type WalletBalance struct {
|
|||||||
|
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
Available *moneyv1.Money `bson:"available" json:"available"`
|
Available *moneyv1.Money `bson:"available" json:"available"`
|
||||||
|
NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"`
|
||||||
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
||||||
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
||||||
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
||||||
@@ -77,10 +80,19 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
||||||
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
||||||
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
||||||
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
|
if m.Description != nil {
|
||||||
|
desc := strings.TrimSpace(*m.Description)
|
||||||
|
if desc == "" {
|
||||||
|
m.Description = nil
|
||||||
|
} else {
|
||||||
|
m.Description = &desc
|
||||||
|
}
|
||||||
|
}
|
||||||
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||||
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||||
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||||
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
|
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
|
||||||
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +100,31 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
func (b *WalletBalance) Normalize() {
|
func (b *WalletBalance) Normalize() {
|
||||||
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddress(address string) string {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if isHexAddress(trimmed) {
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexAddress(value string) bool {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x")
|
||||||
|
if len(trimmed) != 40 && len(trimmed) != 42 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
case r >= 'A' && r <= 'F':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,23 +44,23 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := result.Ping(ctx); err != nil {
|
if err := result.Ping(ctx); err != nil {
|
||||||
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err))
|
result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
walletsStore, err := store.NewWallets(result.logger, result.db)
|
walletsStore, err := store.NewWallets(result.logger, result.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.logger.Error("failed to initialise wallets store", zap.Error(err))
|
result.logger.Error("Failed to initialise wallets store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
transfersStore, err := store.NewTransfers(result.logger, result.db)
|
transfersStore, err := store.NewTransfers(result.logger, result.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.logger.Error("failed to initialise transfers store", zap.Error(err))
|
result.logger.Error("Failed to initialise transfers store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
depositsStore, err := store.NewDeposits(result.logger, result.db)
|
depositsStore, err := store.NewDeposits(result.logger, result.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.logger.Error("failed to initialise deposits store", zap.Error(err))
|
result.logger.Error("Failed to initialise deposits store", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
|
|||||||
}
|
}
|
||||||
for _, def := range indexes {
|
for _, def := range indexes {
|
||||||
if err := repo.CreateIndex(def); err != nil {
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
logger.Error("failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection()))
|
logger.Error("Failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childLogger := logger.Named("deposits")
|
childLogger := logger.Named("deposits")
|
||||||
childLogger.Debug("deposits store initialised")
|
childLogger.Debug("Deposits store initialised")
|
||||||
|
|
||||||
return &Deposits{logger: childLogger, repo: repo}, nil
|
return &Deposits{logger: childLogger, repo: repo}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
|
|||||||
}
|
}
|
||||||
for _, def := range indexes {
|
for _, def := range indexes {
|
||||||
if err := repo.CreateIndex(def); err != nil {
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
logger.Error("failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
|
logger.Error("Failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childLogger := logger.Named("transfers")
|
childLogger := logger.Named("transfers")
|
||||||
childLogger.Debug("transfers store initialised")
|
childLogger.Debug("Transfers store initialised")
|
||||||
|
|
||||||
return &Transfers{
|
return &Transfers{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -89,12 +89,12 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode
|
|||||||
}
|
}
|
||||||
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
|
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
t.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
|
t.logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
|
||||||
return transfer, nil
|
return transfer, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t.logger.Debug("transfer created", zap.String("transfer_ref", transfer.TransferRef))
|
t.logger.Debug("Transfer created", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
return transfer, nil
|
return transfer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*mod
|
|||||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||||
} else {
|
} else {
|
||||||
t.logger.Warn("ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
|
t.logger.Warn("Ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
|||||||
}
|
}
|
||||||
for _, def := range walletIndexes {
|
for _, def := range walletIndexes {
|
||||||
if err := walletRepo.CreateIndex(def); err != nil {
|
if err := walletRepo.CreateIndex(def); err != nil {
|
||||||
logger.Error("failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
|
logger.Error("Failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,13 +70,13 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
|||||||
}
|
}
|
||||||
for _, def := range balanceIndexes {
|
for _, def := range balanceIndexes {
|
||||||
if err := balanceRepo.CreateIndex(def); err != nil {
|
if err := balanceRepo.CreateIndex(def); err != nil {
|
||||||
logger.Error("failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err))
|
logger.Error("Failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childLogger := logger.Named("wallets")
|
childLogger := logger.Named("wallets")
|
||||||
childLogger.Debug("wallet stores initialised")
|
childLogger.Debug("Wallet stores initialised")
|
||||||
|
|
||||||
return &Wallets{
|
return &Wallets{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -99,24 +99,49 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
|
|||||||
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
||||||
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||||
}
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("idempotency_key", wallet.IdempotencyKey),
|
||||||
|
}
|
||||||
|
if wallet.OrganizationRef != "" {
|
||||||
|
fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef))
|
||||||
|
}
|
||||||
|
if wallet.OwnerRef != "" {
|
||||||
|
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
|
||||||
|
}
|
||||||
|
if wallet.Network != "" {
|
||||||
|
fields = append(fields, zap.String("network", wallet.Network))
|
||||||
|
}
|
||||||
|
if wallet.TokenSymbol != "" {
|
||||||
|
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
|
||||||
|
}
|
||||||
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
|
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
|
w.logger.Debug("Wallet already exists", fields...)
|
||||||
return wallet, nil
|
return wallet, nil
|
||||||
}
|
}
|
||||||
|
w.logger.Warn("Wallet create failed", append(fields, zap.Error(err))...)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
|
w.logger.Debug("Wallet created", fields...)
|
||||||
return wallet, nil
|
return wallet, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
|
||||||
walletRef = strings.TrimSpace(walletRef)
|
walletID = strings.TrimSpace(walletID)
|
||||||
if walletRef == "" {
|
if walletID == "" {
|
||||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
}
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("wallet_id", walletID),
|
||||||
|
}
|
||||||
wallet := &model.ManagedWallet{}
|
wallet := &model.ManagedWallet{}
|
||||||
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
|
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
w.logger.Debug("Wallet not found", fields...)
|
||||||
|
} else {
|
||||||
|
w.logger.Warn("Wallet lookup failed", append(fields, zap.Error(err))...)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return wallet, nil
|
return wallet, nil
|
||||||
@@ -124,29 +149,38 @@ func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWall
|
|||||||
|
|
||||||
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||||
query := repository.Query()
|
query := repository.Query()
|
||||||
|
fields := make([]zap.Field, 0, 6)
|
||||||
|
|
||||||
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
||||||
query = query.Filter(repository.Field("organizationRef"), org)
|
query = query.Filter(repository.Field("organizationRef"), org)
|
||||||
|
fields = append(fields, zap.String("organization_ref", org))
|
||||||
}
|
}
|
||||||
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
||||||
query = query.Filter(repository.Field("ownerRef"), owner)
|
query = query.Filter(repository.Field("ownerRef"), owner)
|
||||||
|
fields = append(fields, zap.String("owner_ref", owner))
|
||||||
}
|
}
|
||||||
if network := strings.TrimSpace(filter.Network); network != "" {
|
if network := strings.TrimSpace(filter.Network); network != "" {
|
||||||
query = query.Filter(repository.Field("network"), strings.ToLower(network))
|
normalized := strings.ToLower(network)
|
||||||
|
query = query.Filter(repository.Field("network"), normalized)
|
||||||
|
fields = append(fields, zap.String("network", normalized))
|
||||||
}
|
}
|
||||||
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
||||||
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
|
normalized := strings.ToUpper(token)
|
||||||
|
query = query.Filter(repository.Field("tokenSymbol"), normalized)
|
||||||
|
fields = append(fields, zap.String("token_symbol", normalized))
|
||||||
}
|
}
|
||||||
|
|
||||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||||
|
fields = append(fields, zap.String("cursor", cursor))
|
||||||
} else {
|
} else {
|
||||||
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
w.logger.Warn("Ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := sanitizeWalletLimit(filter.Limit)
|
limit := sanitizeWalletLimit(filter.Limit)
|
||||||
|
fields = append(fields, zap.Int64("limit", limit))
|
||||||
fetchLimit := limit + 1
|
fetchLimit := limit + 1
|
||||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||||
|
|
||||||
@@ -160,8 +194,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
|
||||||
return nil, err
|
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
|
||||||
|
w.logger.Warn("Wallet list failed", append(fields, zap.Error(listErr))...)
|
||||||
|
return nil, listErr
|
||||||
}
|
}
|
||||||
|
|
||||||
nextCursor := ""
|
nextCursor := ""
|
||||||
@@ -171,10 +207,21 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
|||||||
wallets = wallets[:len(wallets)-1]
|
wallets = wallets[:len(wallets)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.ManagedWalletList{
|
result := &model.ManagedWalletList{
|
||||||
Items: wallets,
|
Items: wallets,
|
||||||
NextCursor: nextCursor,
|
NextCursor: nextCursor,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
fields = append(fields,
|
||||||
|
zap.Int("count", len(result.Items)),
|
||||||
|
zap.String("next_cursor", result.NextCursor),
|
||||||
|
)
|
||||||
|
if errors.Is(listErr, merrors.ErrNoData) {
|
||||||
|
w.logger.Debug("Wallet list empty", fields...)
|
||||||
|
} else {
|
||||||
|
w.logger.Debug("Wallet list fetched", fields...)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
||||||
@@ -188,6 +235,7 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
|||||||
if balance.CalculatedAt.IsZero() {
|
if balance.CalculatedAt.IsZero() {
|
||||||
balance.CalculatedAt = time.Now().UTC()
|
balance.CalculatedAt = time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
|
||||||
|
|
||||||
existing := &model.WalletBalance{}
|
existing := &model.WalletBalance{}
|
||||||
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
||||||
@@ -198,28 +246,40 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
|||||||
existing.PendingOutbound = balance.PendingOutbound
|
existing.PendingOutbound = balance.PendingOutbound
|
||||||
existing.CalculatedAt = balance.CalculatedAt
|
existing.CalculatedAt = balance.CalculatedAt
|
||||||
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
||||||
|
w.logger.Warn("Wallet balance update failed", append(fields, zap.Error(err))...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
w.logger.Debug("Wallet balance updated", fields...)
|
||||||
return nil
|
return nil
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
||||||
|
w.logger.Warn("Wallet balance create failed", append(fields, zap.Error(err))...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
w.logger.Debug("Wallet balance created", fields...)
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
|
||||||
walletRef = strings.TrimSpace(walletRef)
|
walletID = strings.TrimSpace(walletID)
|
||||||
if walletRef == "" {
|
if walletID == "" {
|
||||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
}
|
}
|
||||||
|
fields := []zap.Field{zap.String("wallet_ref", walletID)}
|
||||||
balance := &model.WalletBalance{}
|
balance := &model.WalletBalance{}
|
||||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
|
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
w.logger.Debug("Wallet balance not found", fields...)
|
||||||
|
} else {
|
||||||
|
w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
w.logger.Debug("Wallet balance fetched", fields...)
|
||||||
return balance, nil
|
return balance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This service now supports Monetix “payout by card”.
|
This service now supports Monetix “payout by card”.
|
||||||
|
|
||||||
## Runtime entry points
|
## Runtime entry points
|
||||||
- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`.
|
- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `GetCardPayoutStatus`, `ListGatewayInstances`.
|
||||||
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
|
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
|
||||||
- Metrics: Prometheus on `:9404/metrics`.
|
- Metrics: Prometheus on `:9404/metrics`.
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ This service now supports Monetix “payout by card”.
|
|||||||
- `MONETIX_PROJECT_ID` – integer project ID
|
- `MONETIX_PROJECT_ID` – integer project ID
|
||||||
- `MONETIX_SECRET_KEY` – signature secret
|
- `MONETIX_SECRET_KEY` – signature secret
|
||||||
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
||||||
|
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits`
|
||||||
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
||||||
|
|
||||||
## Outbound request (CreateCardPayout)
|
## Outbound request (CreateCardPayout)
|
||||||
@@ -39,7 +40,8 @@ Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_
|
|||||||
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
|
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
|
||||||
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
|
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
|
||||||
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
|
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
|
||||||
- Existing RPC/payout counters remain for compatibility.
|
- `sendico_mntx_gateway_rpc_requests_total{method,status}`
|
||||||
|
- `sendico_mntx_gateway_rpc_latency_seconds{method}`
|
||||||
|
|
||||||
## Notes / PCI
|
## Notes / PCI
|
||||||
- PAN is only logged in masked form; do not persist raw PAN.
|
- PAN is only logged in masked form; do not persist raw PAN.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -16,13 +18,15 @@ type Client interface {
|
|||||||
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||||
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||||
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||||
|
ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type gatewayClient struct {
|
type gatewayClient struct {
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
client mntxv1.MntxGatewayServiceClient
|
client unifiedv1.UnifiedGatewayServiceClient
|
||||||
cfg Config
|
cfg Config
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New dials the Monetix gateway.
|
// New dials the Monetix gateway.
|
||||||
@@ -45,8 +49,9 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
|
|
||||||
return &gatewayClient{
|
return &gatewayClient{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
client: mntxv1.NewMntxGatewayServiceClient(conn),
|
client: unifiedv1.NewUnifiedGatewayServiceClient(conn),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
logger: cfg.Logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,28 +62,45 @@ func (g *gatewayClient) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
timeout := g.cfg.CallTimeout
|
timeout := g.cfg.CallTimeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 5 * time.Second
|
timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("method", method),
|
||||||
|
zap.Duration("timeout", timeout),
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
|
||||||
|
}
|
||||||
|
g.logger.Info("Mntx gateway client call timeout applied", fields...)
|
||||||
return context.WithTimeout(ctx, timeout)
|
return context.WithTimeout(ctx, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx)
|
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.CreateCardPayout(ctx, req)
|
return g.client.CreateCardPayout(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx)
|
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.CreateCardTokenPayout(ctx, req)
|
return g.client.CreateCardTokenPayout(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx)
|
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.GetCardPayoutStatus(ctx, req)
|
return g.client.GetCardPayoutStatus(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||||
|
ctx, cancel := g.callContext(ctx, "ListGatewayInstances")
|
||||||
|
defer cancel()
|
||||||
|
return g.client.ListGatewayInstances(ctx, req)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
// Config holds Monetix gateway client settings.
|
// Config holds Monetix gateway client settings.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Address string
|
Address string
|
||||||
DialTimeout time.Duration
|
DialTimeout time.Duration
|
||||||
CallTimeout time.Duration
|
CallTimeout time.Duration
|
||||||
|
Logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) setDefaults() {
|
func (c *Config) setDefaults() {
|
||||||
@@ -16,4 +21,7 @@ func (c *Config) setDefaults() {
|
|||||||
if c.CallTimeout <= 0 {
|
if c.CallTimeout <= 0 {
|
||||||
c.CallTimeout = 10 * time.Second
|
c.CallTimeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
if c.Logger == nil {
|
||||||
|
c.Logger = zap.NewNop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Fake struct {
|
|||||||
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||||
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||||
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||||
|
ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
@@ -34,4 +35,11 @@ func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayou
|
|||||||
return &mntxv1.GetCardPayoutStatusResponse{}, nil
|
return &mntxv1.GetCardPayoutStatusResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||||
|
if f.ListGatewayInstancesFn != nil {
|
||||||
|
return f.ListGatewayInstancesFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &mntxv1.ListGatewayInstancesResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) Close() error { return nil }
|
func (f *Fake) Close() error { return nil }
|
||||||
|
|||||||
@@ -26,12 +26,20 @@ monetix:
|
|||||||
base_url_env: MONETIX_BASE_URL
|
base_url_env: MONETIX_BASE_URL
|
||||||
project_id_env: MONETIX_PROJECT_ID
|
project_id_env: MONETIX_PROJECT_ID
|
||||||
secret_key_env: MONETIX_SECRET_KEY
|
secret_key_env: MONETIX_SECRET_KEY
|
||||||
allowed_currencies: ["USD", "EUR"]
|
allowed_currencies: ["RUB"]
|
||||||
require_customer_address: false
|
require_customer_address: false
|
||||||
request_timeout_seconds: 15
|
request_timeout_seconds: 15
|
||||||
status_success: "success"
|
status_success: "success"
|
||||||
status_processing: "processing"
|
status_processing: "processing"
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
id: "monetix"
|
||||||
|
is_enabled: true
|
||||||
|
network: "VISA_DIRECT"
|
||||||
|
currencies: ["RUB"]
|
||||||
|
limits:
|
||||||
|
min_amount: "0"
|
||||||
|
|
||||||
http:
|
http:
|
||||||
callback:
|
callback:
|
||||||
address: ":8084"
|
address: ":8084"
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/shopspring/decimal v1.4.0
|
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -24,6 +22,7 @@ require (
|
|||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -50,5 +49,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i
|
|||||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
@@ -214,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||||
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -28,14 +30,16 @@ type Imp struct {
|
|||||||
file string
|
file string
|
||||||
debug bool
|
debug bool
|
||||||
|
|
||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[struct{}]
|
app *grpcapp.App[struct{}]
|
||||||
http *http.Server
|
http *http.Server
|
||||||
|
service *mntxservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
Monetix monetixConfig `yaml:"monetix"`
|
Monetix monetixConfig `yaml:"monetix"`
|
||||||
|
Gateway gatewayConfig `yaml:"gateway"`
|
||||||
HTTP httpConfig `yaml:"http"`
|
HTTP httpConfig `yaml:"http"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,33 @@ type monetixConfig struct {
|
|||||||
StatusProcessing string `yaml:"status_processing"`
|
StatusProcessing string `yaml:"status_processing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gatewayConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Network string `yaml:"network"`
|
||||||
|
Currencies []string `yaml:"currencies"`
|
||||||
|
IsEnabled *bool `yaml:"is_enabled"`
|
||||||
|
Limits limitsConfig `yaml:"limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitsConfig struct {
|
||||||
|
MinAmount string `yaml:"min_amount"`
|
||||||
|
MaxAmount string `yaml:"max_amount"`
|
||||||
|
PerTxMaxFee string `yaml:"per_tx_max_fee"`
|
||||||
|
PerTxMinAmount string `yaml:"per_tx_min_amount"`
|
||||||
|
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
|
||||||
|
VolumeLimit map[string]string `yaml:"volume_limit"`
|
||||||
|
VelocityLimit map[string]int `yaml:"velocity_limit"`
|
||||||
|
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitsOverrideCfg struct {
|
||||||
|
MaxVolume string `yaml:"max_volume"`
|
||||||
|
MinAmount string `yaml:"min_amount"`
|
||||||
|
MaxAmount string `yaml:"max_amount"`
|
||||||
|
MaxFee string `yaml:"max_fee"`
|
||||||
|
MaxOps int `yaml:"max_ops"`
|
||||||
|
}
|
||||||
|
|
||||||
type httpConfig struct {
|
type httpConfig struct {
|
||||||
Callback callbackConfig `yaml:"callback"`
|
Callback callbackConfig `yaml:"callback"`
|
||||||
}
|
}
|
||||||
@@ -86,6 +117,9 @@ func (i *Imp) Shutdown() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
if i.http != nil {
|
if i.http != nil {
|
||||||
_ = i.http.Shutdown(ctx)
|
_ = i.http.Shutdown(ctx)
|
||||||
i.http = nil
|
i.http = nil
|
||||||
@@ -95,28 +129,68 @@ func (i *Imp) Shutdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
|
i.logger.Info("Starting Monetix gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
||||||
|
|
||||||
cfg, err := i.loadConfig()
|
cfg, err := i.loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
|
i.logger.Info("Configuration loaded",
|
||||||
|
zap.String("grpc_address", cfg.GRPC.Address),
|
||||||
|
zap.String("metrics_address", cfg.Metrics.Address),
|
||||||
|
)
|
||||||
|
|
||||||
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to resolve callback configuration", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
i.logger.Info("Monetix configuration resolved",
|
||||||
|
zap.Bool("base_url_set", strings.TrimSpace(monetixCfg.BaseURL) != ""),
|
||||||
|
zap.Int64("project_id", monetixCfg.ProjectID),
|
||||||
|
zap.Bool("secret_key_set", strings.TrimSpace(monetixCfg.SecretKey) != ""),
|
||||||
|
zap.Int("allowed_currencies", len(monetixCfg.AllowedCurrencies)),
|
||||||
|
zap.Bool("require_customer_address", monetixCfg.RequireCustomerAddress),
|
||||||
|
zap.Duration("request_timeout", monetixCfg.RequestTimeout),
|
||||||
|
zap.String("status_success", monetixCfg.SuccessStatus()),
|
||||||
|
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
||||||
|
)
|
||||||
|
|
||||||
|
gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, monetixCfg)
|
||||||
|
if gatewayDescriptor != nil {
|
||||||
|
i.logger.Info("Gateway descriptor resolved",
|
||||||
|
zap.String("id", gatewayDescriptor.GetId()),
|
||||||
|
zap.String("rail", gatewayDescriptor.GetRail().String()),
|
||||||
|
zap.String("network", gatewayDescriptor.GetNetwork()),
|
||||||
|
zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())),
|
||||||
|
zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
i.logger.Info("Callback configuration resolved",
|
||||||
|
zap.String("address", callbackCfg.Address),
|
||||||
|
zap.String("path", callbackCfg.Path),
|
||||||
|
zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)),
|
||||||
|
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
|
||||||
|
)
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
svc := mntxservice.NewService(logger,
|
svc := mntxservice.NewService(logger,
|
||||||
mntxservice.WithProducer(producer),
|
mntxservice.WithProducer(producer),
|
||||||
mntxservice.WithMonetixConfig(monetixCfg),
|
mntxservice.WithMonetixConfig(monetixCfg),
|
||||||
|
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
|
||||||
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
||||||
)
|
)
|
||||||
|
i.service = svc
|
||||||
|
|
||||||
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
|
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -137,7 +211,7 @@ func (i *Imp) Start() error {
|
|||||||
func (i *Imp) loadConfig() (*config, error) {
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +219,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
Config: &grpcapp.Config{},
|
Config: &grpcapp.Config{},
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
i.logger.Error("failed to parse configuration", zap.Error(err))
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +290,129 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor {
|
||||||
|
id := strings.TrimSpace(cfg.ID)
|
||||||
|
if id == "" {
|
||||||
|
id = "monetix"
|
||||||
|
}
|
||||||
|
|
||||||
|
network := strings.ToUpper(strings.TrimSpace(cfg.Network))
|
||||||
|
currencies := normalizeCurrencies(cfg.Currencies)
|
||||||
|
if len(currencies) == 0 {
|
||||||
|
currencies = normalizeCurrencies(monetixCfg.AllowedCurrencies)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
if cfg.IsEnabled != nil {
|
||||||
|
enabled = *cfg.IsEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := buildGatewayLimits(cfg.Limits)
|
||||||
|
if limits == nil {
|
||||||
|
limits = &gatewayv1.Limits{MinAmount: "0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(appversion.Version)
|
||||||
|
|
||||||
|
return &gatewayv1.GatewayInstanceDescriptor{
|
||||||
|
Id: id,
|
||||||
|
Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT,
|
||||||
|
Network: network,
|
||||||
|
Currencies: currencies,
|
||||||
|
Capabilities: &gatewayv1.RailCapabilities{
|
||||||
|
CanPayOut: true,
|
||||||
|
CanPayIn: false,
|
||||||
|
CanReadBalance: false,
|
||||||
|
CanSendFee: false,
|
||||||
|
RequiresObserveConfirm: false,
|
||||||
|
},
|
||||||
|
Limits: limits,
|
||||||
|
Version: version,
|
||||||
|
IsEnabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCurrencies(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
clean := strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if clean == "" || seen[clean] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[clean] = true
|
||||||
|
result = append(result, clean)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits {
|
||||||
|
hasValue := strings.TrimSpace(cfg.MinAmount) != "" ||
|
||||||
|
strings.TrimSpace(cfg.MaxAmount) != "" ||
|
||||||
|
strings.TrimSpace(cfg.PerTxMaxFee) != "" ||
|
||||||
|
strings.TrimSpace(cfg.PerTxMinAmount) != "" ||
|
||||||
|
strings.TrimSpace(cfg.PerTxMaxAmount) != "" ||
|
||||||
|
len(cfg.VolumeLimit) > 0 ||
|
||||||
|
len(cfg.VelocityLimit) > 0 ||
|
||||||
|
len(cfg.CurrencyLimits) > 0
|
||||||
|
if !hasValue {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := &gatewayv1.Limits{
|
||||||
|
MinAmount: strings.TrimSpace(cfg.MinAmount),
|
||||||
|
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
|
||||||
|
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
|
||||||
|
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
|
||||||
|
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VolumeLimit) > 0 {
|
||||||
|
limits.VolumeLimit = map[string]string{}
|
||||||
|
for key, value := range cfg.VolumeLimit {
|
||||||
|
bucket := strings.TrimSpace(key)
|
||||||
|
amount := strings.TrimSpace(value)
|
||||||
|
if bucket == "" || amount == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VolumeLimit[bucket] = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VelocityLimit) > 0 {
|
||||||
|
limits.VelocityLimit = map[string]int32{}
|
||||||
|
for key, value := range cfg.VelocityLimit {
|
||||||
|
bucket := strings.TrimSpace(key)
|
||||||
|
if bucket == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VelocityLimit[bucket] = int32(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.CurrencyLimits) > 0 {
|
||||||
|
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
|
||||||
|
for key, override := range cfg.CurrencyLimits {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(key))
|
||||||
|
if currency == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{
|
||||||
|
MaxVolume: strings.TrimSpace(override.MaxVolume),
|
||||||
|
MinAmount: strings.TrimSpace(override.MinAmount),
|
||||||
|
MaxAmount: strings.TrimSpace(override.MaxAmount),
|
||||||
|
MaxFee: strings.TrimSpace(override.MaxFee),
|
||||||
|
MaxOps: int32(override.MaxOps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return limits
|
||||||
|
}
|
||||||
|
|
||||||
type callbackRuntimeConfig struct {
|
type callbackRuntimeConfig struct {
|
||||||
Address string
|
Address string
|
||||||
Path string
|
Path string
|
||||||
@@ -245,7 +442,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
|||||||
}
|
}
|
||||||
_, block, err := net.ParseCIDR(clean)
|
_, block, err := net.ParseCIDR(clean)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cidrs = append(cidrs, block)
|
cidrs = append(cidrs, block)
|
||||||
@@ -270,20 +467,36 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := i.logger.Named("callback_http")
|
||||||
|
log.Debug("Callback request received",
|
||||||
|
zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)),
|
||||||
|
zap.String("path", r.URL.Path),
|
||||||
|
zap.String("method", r.Method),
|
||||||
|
)
|
||||||
|
|
||||||
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
||||||
|
ip := clientIPFromRequest(r)
|
||||||
|
remoteIP := ""
|
||||||
|
if ip != nil {
|
||||||
|
remoteIP = ip.String()
|
||||||
|
}
|
||||||
|
log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP))
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("Callback body read failed", zap.Error(err))
|
||||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
|
||||||
http.Error(w, err.Error(), status)
|
http.Error(w, err.Error(), status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Debug("Callback processed", zap.Int("status", status))
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,7 +514,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
|
i.logger.Warn("Monetix callback server stopped with error", zap.Error(err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientIPFromRequest(t *testing.T) {
|
||||||
|
req := &http.Request{
|
||||||
|
Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}},
|
||||||
|
RemoteAddr: "9.8.7.6:1234",
|
||||||
|
}
|
||||||
|
ip := clientIPFromRequest(req)
|
||||||
|
if ip == nil || ip.String() != "1.2.3.4" {
|
||||||
|
t.Fatalf("expected forwarded ip, got %v", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = &http.Request{RemoteAddr: "9.8.7.6:1234"}
|
||||||
|
ip = clientIPFromRequest(req)
|
||||||
|
if ip == nil || ip.String() != "9.8.7.6" {
|
||||||
|
t.Fatalf("expected remote addr ip, got %v", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = &http.Request{RemoteAddr: "invalid"}
|
||||||
|
ip = clientIPFromRequest(req)
|
||||||
|
if ip != nil {
|
||||||
|
t.Fatalf("expected nil ip, got %v", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientAllowed(t *testing.T) {
|
||||||
|
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse cidr: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
||||||
|
if !clientAllowed(allowedReq, []*net.IPNet{cidr}) {
|
||||||
|
t.Fatalf("expected allowed request")
|
||||||
|
}
|
||||||
|
|
||||||
|
deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||||
|
if clientAllowed(deniedReq, []*net.IPNet{cidr}) {
|
||||||
|
t.Fatalf("expected denied request")
|
||||||
|
}
|
||||||
|
|
||||||
|
openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||||
|
if !clientAllowed(openReq, nil) {
|
||||||
|
t.Fatalf("expected allow when no cidrs are configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,10 +45,11 @@ type callbackOperation struct {
|
|||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
} `json:"sum_converted"`
|
} `json:"sum_converted"`
|
||||||
Provider struct {
|
Provider struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
PaymentID string `json:"payment_id"`
|
PaymentID string `json:"payment_id"`
|
||||||
Date string `json:"date"`
|
AuthCode string `json:"auth_code"`
|
||||||
AuthCode string `json:"auth_code"`
|
EndpointID int64 `json:"endpoint_id"`
|
||||||
|
Date string `json:"date"`
|
||||||
} `json:"provider"`
|
} `json:"provider"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -55,7 +59,11 @@ type monetixCallback struct {
|
|||||||
ProjectID int64 `json:"project_id"`
|
ProjectID int64 `json:"project_id"`
|
||||||
Payment callbackPayment `json:"payment"`
|
Payment callbackPayment `json:"payment"`
|
||||||
Account struct {
|
Account struct {
|
||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
CardHolder string `json:"card_holder"`
|
||||||
|
ExpiryMonth string `json:"expiry_month"`
|
||||||
|
ExpiryYear string `json:"expiry_year"`
|
||||||
} `json:"account"`
|
} `json:"account"`
|
||||||
Customer struct {
|
Customer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -66,9 +74,12 @@ type monetixCallback struct {
|
|||||||
|
|
||||||
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
||||||
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
||||||
|
log := s.logger.Named("callback")
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
|
log.Warn("Card payout processor not initialised")
|
||||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||||
}
|
}
|
||||||
|
log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload)))
|
||||||
return s.card.ProcessCallback(ctx, payload)
|
return s.card.ProcessCallback(ctx, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,17 +127,48 @@ func fallbackProviderPaymentID(cb monetixCallback) string {
|
|||||||
return cb.Payment.ID
|
return cb.Payment.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyCallbackSignature(cb monetixCallback, secret string) error {
|
func verifyCallbackSignature(payload []byte, secret string) (string, error) {
|
||||||
expected := cb.Signature
|
root, err := decodeCallbackPayload(payload)
|
||||||
cb.Signature = ""
|
|
||||||
calculated, err := monetix.SignPayload(cb, secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if subtleConstantTimeCompare(expected, calculated) {
|
signature, ok := signatureFromPayload(root)
|
||||||
return nil
|
if !ok || strings.TrimSpace(signature) == "" {
|
||||||
|
return "", merrors.InvalidArgument("signature is missing")
|
||||||
}
|
}
|
||||||
return merrors.DataConflict("signature mismatch")
|
calculated, err := monetix.SignPayload(root, secret)
|
||||||
|
if err != nil {
|
||||||
|
return signature, err
|
||||||
|
}
|
||||||
|
if subtleConstantTimeCompare(signature, calculated) {
|
||||||
|
return signature, nil
|
||||||
|
}
|
||||||
|
return signature, merrors.DataConflict("signature mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCallbackPayload(payload []byte) (any, error) {
|
||||||
|
var root any
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signatureFromPayload(root any) (string, bool) {
|
||||||
|
payload, ok := root.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for key, value := range payload {
|
||||||
|
if !strings.EqualFold(key, "signature") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signature, ok := value.(string)
|
||||||
|
return signature, ok
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func subtleConstantTimeCompare(a, b string) bool {
|
func subtleConstantTimeCompare(a, b string) bool {
|
||||||
|
|||||||
139
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
139
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixedClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fixedClock) Now() time.Time {
|
||||||
|
return f.now
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseCallback() monetixCallback {
|
||||||
|
cb := monetixCallback{
|
||||||
|
ProjectID: 42,
|
||||||
|
}
|
||||||
|
cb.Payment.ID = "payout-1"
|
||||||
|
cb.Payment.Status = "success"
|
||||||
|
cb.Payment.Sum.Amount = 5000
|
||||||
|
cb.Payment.Sum.Currency = "usd"
|
||||||
|
cb.Customer.ID = "cust-1"
|
||||||
|
cb.Operation.Status = "success"
|
||||||
|
cb.Operation.Code = ""
|
||||||
|
cb.Operation.Message = "ok"
|
||||||
|
cb.Operation.RequestID = "req-1"
|
||||||
|
cb.Operation.Provider.PaymentID = "prov-1"
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
||||||
|
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
cfg := monetix.DefaultConfig()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
paymentStatus string
|
||||||
|
operationStatus string
|
||||||
|
code string
|
||||||
|
expectedStatus mntxv1.PayoutStatus
|
||||||
|
expectedOutcome string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
paymentStatus: "success",
|
||||||
|
operationStatus: "success",
|
||||||
|
code: "0",
|
||||||
|
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
|
||||||
|
expectedOutcome: monetix.OutcomeSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "processing",
|
||||||
|
paymentStatus: "processing",
|
||||||
|
operationStatus: "success",
|
||||||
|
code: "",
|
||||||
|
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||||
|
expectedOutcome: monetix.OutcomeProcessing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "decline",
|
||||||
|
paymentStatus: "failed",
|
||||||
|
operationStatus: "failed",
|
||||||
|
code: "1",
|
||||||
|
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
|
||||||
|
expectedOutcome: monetix.OutcomeDecline,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cb := baseCallback()
|
||||||
|
cb.Payment.Status = tc.paymentStatus
|
||||||
|
cb.Operation.Status = tc.operationStatus
|
||||||
|
cb.Operation.Code = tc.code
|
||||||
|
|
||||||
|
state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb)
|
||||||
|
if state.Status != tc.expectedStatus {
|
||||||
|
t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status)
|
||||||
|
}
|
||||||
|
if outcome != tc.expectedOutcome {
|
||||||
|
t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome)
|
||||||
|
}
|
||||||
|
if state.Currency != "USD" {
|
||||||
|
t.Fatalf("expected currency USD, got %q", state.Currency)
|
||||||
|
}
|
||||||
|
if !state.UpdatedAt.AsTime().Equal(now) {
|
||||||
|
t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallbackProviderPaymentID(t *testing.T) {
|
||||||
|
cb := baseCallback()
|
||||||
|
if got := fallbackProviderPaymentID(cb); got != "prov-1" {
|
||||||
|
t.Fatalf("expected provider payment id, got %q", got)
|
||||||
|
}
|
||||||
|
cb.Operation.Provider.PaymentID = ""
|
||||||
|
if got := fallbackProviderPaymentID(cb); got != "req-1" {
|
||||||
|
t.Fatalf("expected request id fallback, got %q", got)
|
||||||
|
}
|
||||||
|
cb.Operation.RequestID = ""
|
||||||
|
if got := fallbackProviderPaymentID(cb); got != "payout-1" {
|
||||||
|
t.Fatalf("expected payment id fallback, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCallbackSignature(t *testing.T) {
|
||||||
|
secret := "secret"
|
||||||
|
cb := baseCallback()
|
||||||
|
|
||||||
|
sig, err := monetix.SignPayload(cb, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to sign payload: %v", err)
|
||||||
|
}
|
||||||
|
cb.Signature = sig
|
||||||
|
payload, err := json.Marshal(cb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal callback: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := verifyCallbackSignature(payload, secret); err != nil {
|
||||||
|
t.Fatalf("expected valid signature, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.Signature = "invalid"
|
||||||
|
payload, err = json.Marshal(cb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal callback: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := verifyCallbackSignature(payload, secret); err == nil {
|
||||||
|
t.Fatalf("expected signature mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,14 +18,24 @@ func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
||||||
|
log := s.logger.Named("card_payout")
|
||||||
|
log.Info("Create card payout request received",
|
||||||
|
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||||
|
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||||
|
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||||
|
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||||
|
)
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
|
log.Warn("Card payout processor not initialised")
|
||||||
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.card.Submit(ctx, req)
|
resp, err := s.card.Submit(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("Card payout submission failed", zap.Error(err))
|
||||||
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
|
log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,14 +44,24 @@ func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTok
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
||||||
|
log := s.logger.Named("card_token_payout")
|
||||||
|
log.Info("Create card token payout request received",
|
||||||
|
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||||
|
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||||
|
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||||
|
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||||
|
)
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
|
log.Warn("Card payout processor not initialised")
|
||||||
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.card.SubmitToken(ctx, req)
|
resp, err := s.card.SubmitToken(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("Card token payout submission failed", zap.Error(err))
|
||||||
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
|
log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,14 +70,22 @@ func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeR
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
||||||
|
log := s.logger.Named("card_tokenize")
|
||||||
|
log.Info("Create card token request received",
|
||||||
|
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
||||||
|
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||||
|
)
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
|
log.Warn("Card payout processor not initialised")
|
||||||
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.card.Tokenize(ctx, req)
|
resp, err := s.card.Tokenize(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("Card tokenization failed", zap.Error(err))
|
||||||
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
|
log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess()))
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,14 +94,19 @@ func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
||||||
|
log := s.logger.Named("card_payout_status")
|
||||||
|
log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())))
|
||||||
if s.card == nil {
|
if s.card == nil {
|
||||||
|
log.Warn("Card payout processor not initialised")
|
||||||
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("Card payout status lookup failed", zap.Error(err))
|
||||||
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
||||||
}
|
}
|
||||||
|
log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
||||||
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user