service backend
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env.version
|
||||||
32
api/billing/fees/.air.toml
Normal file
32
api/billing/fees/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Config file for Air in TOML format
|
||||||
|
|
||||||
|
root = "./../.."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildDate=$(date)'\""
|
||||||
|
bin = "./app"
|
||||||
|
full_bin = "./app --debug --config.file=config.yml"
|
||||||
|
include_ext = ["go", "yaml", "yml"]
|
||||||
|
exclude_dir = ["billing/fees/tmp", "pkg/.git", "billing/fees/env"]
|
||||||
|
exclude_regex = ["_test\\.go"]
|
||||||
|
exclude_unchanged = true
|
||||||
|
follow_symlink = true
|
||||||
|
log = "air.log"
|
||||||
|
delay = 0
|
||||||
|
stop_on_error = true
|
||||||
|
send_interrupt = true
|
||||||
|
kill_delay = 500
|
||||||
|
args_bin = []
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
main = "magenta"
|
||||||
|
watcher = "cyan"
|
||||||
|
build = "yellow"
|
||||||
|
runner = "green"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
3
api/billing/fees/.gitignore
vendored
Normal file
3
api/billing/fees/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
40
api/billing/fees/config.yml
Normal file
40
api/billing/fees/config.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50060"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9402"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: FEES_MONGO_HOST
|
||||||
|
port_env: FEES_MONGO_PORT
|
||||||
|
database_env: FEES_MONGO_DATABASE
|
||||||
|
user_env: FEES_MONGO_USER
|
||||||
|
password_env: FEES_MONGO_PASSWORD
|
||||||
|
auth_source_env: FEES_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: FEES_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
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: Billing Fees Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
|
oracle:
|
||||||
|
address: "sendico_fx_oracle:50051"
|
||||||
|
dial_timeout_seconds: 5
|
||||||
|
call_timeout_seconds: 3
|
||||||
|
insecure: true
|
||||||
1
api/billing/fees/env/.gitignore
vendored
Normal file
1
api/billing/fees/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env.api
|
||||||
54
api/billing/fees/go.mod
Normal file
54
api/billing/fees/go.mod
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
module github.com/tech/sendico/billing/fees
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/tech/sendico/fx/oracle v0.0.0
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
google.golang.org/grpc v1.76.0
|
||||||
|
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.132.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/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.1 // 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.47.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.2 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10
|
||||||
|
)
|
||||||
225
api/billing/fees/go.sum
Normal file
225
api/billing/fees/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.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||||
|
github.com/casbin/casbin/v2 v2.132.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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
|
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.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||||
|
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||||
|
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||||
|
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.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||||
|
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||||
|
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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.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.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
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-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/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=
|
||||||
28
api/billing/fees/internal/appversion/version.go
Normal file
28
api/billing/fees/internal/appversion/version.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create initialises a version.Printer with the build details for this service.
|
||||||
|
func Create() version.Printer {
|
||||||
|
info := version.Info{
|
||||||
|
Program: "Sendico Billing Fees Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
163
api/billing/fees/internal/server/internal/serverimp.go
Normal file
163
api/billing/fees/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
mongostorage "github.com/tech/sendico/billing/fees/storage/mongo"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
config *config
|
||||||
|
app *grpcapp.App[storage.Repository]
|
||||||
|
oracleClient oracleclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Oracle OracleConfig `yaml:"oracle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OracleConfig struct {
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
|
||||||
|
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
|
||||||
|
InsecureTransport bool `yaml:"insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c OracleConfig) dialTimeout() time.Duration {
|
||||||
|
if c.DialTimeoutSecs <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c OracleConfig) callTimeout() time.Duration {
|
||||||
|
if c.CallTimeoutSecs <= 0 {
|
||||||
|
return 3 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initialises the billing fees server implementation.
|
||||||
|
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) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
if i.oracleClient != nil {
|
||||||
|
_ = i.oracleClient.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if i.oracleClient != nil {
|
||||||
|
_ = i.oracleClient.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
|
return mongostorage.New(logger, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
var oracleClient oracleclient.Client
|
||||||
|
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
||||||
|
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||||
|
Address: addr,
|
||||||
|
DialTimeout: cfg.Oracle.dialTimeout(),
|
||||||
|
CallTimeout: cfg.Oracle.callTimeout(),
|
||||||
|
Insecure: cfg.Oracle.InsecureTransport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
|
||||||
|
} else {
|
||||||
|
oracleClient = oc
|
||||||
|
i.oracleClient = oc
|
||||||
|
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
|
opts := []fees.Option{}
|
||||||
|
if oracleClient != nil {
|
||||||
|
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||||
|
}
|
||||||
|
return fees.NewService(logger, repo, producer, opts...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.app = app
|
||||||
|
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
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{Config: &grpcapp.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.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50060",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
12
api/billing/fees/internal/server/server.go
Normal file
12
api/billing/fees/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/billing/fees/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create constructs the billing fees server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
449
api/billing/fees/internal/service/fees/calculator.go
Normal file
449
api/billing/fees/internal/service/fees/calculator.go
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||||
|
type Calculator interface {
|
||||||
|
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculationResult contains derived fee lines and audit metadata.
|
||||||
|
type CalculationResult struct {
|
||||||
|
Lines []*feesv1.DerivedPostingLine
|
||||||
|
Applied []*feesv1.AppliedRule
|
||||||
|
FxUsed *feesv1.FXUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteCalculator is the default Calculator implementation.
|
||||||
|
type fxOracle interface {
|
||||||
|
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteCalculator struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
oracle fxOracle
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
||||||
|
return "eCalculator{
|
||||||
|
logger: logger.Named("calculator"),
|
||||||
|
oracle: oracle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
||||||
|
if plan == nil {
|
||||||
|
return nil, merrors.InvalidArgument("plan is required")
|
||||||
|
}
|
||||||
|
if intent == nil {
|
||||||
|
return nil, merrors.InvalidArgument("intent is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger := convertTrigger(intent.GetTrigger())
|
||||||
|
if trigger == model.TriggerUnspecified {
|
||||||
|
return nil, merrors.InvalidArgument("unsupported trigger")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid base amount")
|
||||||
|
}
|
||||||
|
if baseAmount.Sign() < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
||||||
|
|
||||||
|
rules := make([]model.FeeRule, len(plan.Rules))
|
||||||
|
copy(rules, plan.Rules)
|
||||||
|
sort.SliceStable(rules, func(i, j int) bool {
|
||||||
|
if rules[i].Priority == rules[j].Priority {
|
||||||
|
return rules[i].RuleID < rules[j].RuleID
|
||||||
|
}
|
||||||
|
return rules[i].Priority < rules[j].Priority
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||||
|
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||||
|
|
||||||
|
planID := ""
|
||||||
|
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||||
|
planID = planRef.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||||
|
if ledgerAccountRef == "" {
|
||||||
|
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||||
|
if calcErr != nil {
|
||||||
|
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
|
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if amount.Sign() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := intent.GetBaseAmount().GetCurrency()
|
||||||
|
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||||
|
currency = override
|
||||||
|
}
|
||||||
|
|
||||||
|
entrySide := mapEntrySide(rule.EntrySide)
|
||||||
|
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||||
|
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]string{
|
||||||
|
"fee_rule_id": rule.RuleID,
|
||||||
|
}
|
||||||
|
if planID != "" {
|
||||||
|
meta["fee_plan_id"] = planID
|
||||||
|
}
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
||||||
|
meta["tax_code"] = taxCode
|
||||||
|
}
|
||||||
|
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
||||||
|
meta["tax_rate"] = taxRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, &feesv1.DerivedPostingLine{
|
||||||
|
LedgerAccountRef: ledgerAccountRef,
|
||||||
|
Money: &moneyv1.Money{
|
||||||
|
Amount: dmath.FormatRat(amount, scale),
|
||||||
|
Currency: currency,
|
||||||
|
},
|
||||||
|
LineType: mapLineType(rule.LineType),
|
||||||
|
Side: entrySide,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
applied = append(applied, &feesv1.AppliedRule{
|
||||||
|
RuleId: rule.RuleID,
|
||||||
|
RuleVersion: planID,
|
||||||
|
Formula: rule.Formula,
|
||||||
|
Rounding: mapRoundingMode(rule.Rounding),
|
||||||
|
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
||||||
|
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
||||||
|
Parameters: cloneStringMap(rule.Metadata),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var fxUsed *feesv1.FXUsed
|
||||||
|
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
||||||
|
fxUsed = c.buildFxUsed(ctx, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CalculationResult{
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fxUsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
||||||
|
scale, err := resolveRuleScale(rule, baseScale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := new(big.Rat)
|
||||||
|
|
||||||
|
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||||
|
percentageRat, perr := dmath.RatFromString(percentage)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||||
|
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||||
|
if ferr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, fixedRat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||||
|
minRat, merr := dmath.RatFromString(minStr)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, minRat) < 0 {
|
||||||
|
result = new(big.Rat).Set(minRat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||||
|
maxRat, merr := dmath.RatFromString(maxStr)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, maxRat) > 0 {
|
||||||
|
result = new(big.Rat).Set(maxRat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Sign() < 0 {
|
||||||
|
result = new(big.Rat).Abs(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, 0, rerr
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded, scale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
attrFxBaseCurrency = "fx_base_currency"
|
||||||
|
attrFxQuoteCurrency = "fx_quote_currency"
|
||||||
|
attrFxProvider = "fx_provider"
|
||||||
|
attrFxSide = "fx_side"
|
||||||
|
attrFxRateOverride = "fx_rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
||||||
|
if intent == nil || c.oracle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := intent.GetAttributes()
|
||||||
|
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||||
|
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||||
|
if base == "" || quote == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
||||||
|
provider := strings.TrimSpace(attrs[attrFxProvider])
|
||||||
|
|
||||||
|
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
||||||
|
Meta: oracleclient.RequestMeta{},
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Mid
|
||||||
|
}
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Ask
|
||||||
|
}
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Bid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &feesv1.FXUsed{
|
||||||
|
Pair: pair,
|
||||||
|
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
||||||
|
Rate: &moneyv1.Decimal{Value: rateValue},
|
||||||
|
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
||||||
|
Provider: snapshot.Provider,
|
||||||
|
RateRef: snapshot.RateRef,
|
||||||
|
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFxSide(value string) fxv1.Side {
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "buy_base", "buy_base_sell_quote", "buy":
|
||||||
|
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||||
|
case "sell_base", "sell_base_buy_quote", "sell":
|
||||||
|
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||||
|
default:
|
||||||
|
return fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferScale(amount string) uint32 {
|
||||||
|
value := strings.TrimSpace(amount)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||||
|
value = value[1:]
|
||||||
|
}
|
||||||
|
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||||
|
return uint32(len(value[dot+1:]))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
||||||
|
if rule.Trigger != trigger {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.EffectiveFrom.After(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ruleMatchesAttributes(rule, attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
for _, field := range []string{"scale", "decimals", "precision"} {
|
||||||
|
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
||||||
|
return parseScale(field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScale(field, value string) (uint32, error) {
|
||||||
|
clean := strings.TrimSpace(value)
|
||||||
|
if clean == "" {
|
||||||
|
return 0, merrors.InvalidArgument(field + " is empty")
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||||
|
}
|
||||||
|
return uint32(parsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataValue(meta map[string]string, key string) string {
|
||||||
|
if meta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(meta[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
cloned[k] = v
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
||||||
|
if len(rule.AppliesTo) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for key, value := range rule.AppliesTo {
|
||||||
|
if attributes == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
|
switch trigger {
|
||||||
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
|
return model.TriggerCapture
|
||||||
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||||
|
switch strings.ToLower(lineType) {
|
||||||
|
case "tax":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||||
|
case "spread":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||||
|
case "reversal":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||||
|
default:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
||||||
|
switch strings.ToLower(entrySide) {
|
||||||
|
case "debit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||||
|
case "credit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
default:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDecimalRounding(mode string) dmath.RoundingMode {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "half_up":
|
||||||
|
return dmath.RoundingModeHalfUp
|
||||||
|
case "down":
|
||||||
|
return dmath.RoundingModeDown
|
||||||
|
case "half_even", "bankers":
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
default:
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||||
|
switch strings.ToLower(mode) {
|
||||||
|
case "half_up":
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||||
|
case "down":
|
||||||
|
return moneyv1.RoundingMode_ROUND_DOWN
|
||||||
|
default:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||||
|
}
|
||||||
|
}
|
||||||
71
api/billing/fees/internal/service/fees/metrics.go
Normal file
71
api/billing/fees/internal/service/fees/metrics.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
quoteRequestsTotal *prometheus.CounterVec
|
||||||
|
quoteLatency *prometheus.HistogramVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
quoteRequestsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "fees",
|
||||||
|
Name: "requests_total",
|
||||||
|
Help: "Total number of fee service requests processed.",
|
||||||
|
},
|
||||||
|
[]string{"call", "trigger", "status", "fx_used"},
|
||||||
|
)
|
||||||
|
|
||||||
|
quoteLatency = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "fees",
|
||||||
|
Name: "request_latency_seconds",
|
||||||
|
Help: "Latency of fee service requests.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
},
|
||||||
|
[]string{"call", "trigger", "status", "fx_used"},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxUsed bool, took time.Duration) {
|
||||||
|
triggerLabel := trigger.String()
|
||||||
|
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
|
triggerLabel = "TRIGGER_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
fxLabel := strconv.FormatBool(fxUsed)
|
||||||
|
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
||||||
|
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFromError(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
if !ok {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
code := st.Code()
|
||||||
|
if code == codes.OK {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
return strings.ToLower(code.String())
|
||||||
|
}
|
||||||
37
api/billing/fees/internal/service/fees/options.go
Normal file
37
api/billing/fees/internal/service/fees/options.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option configures a Service instance.
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// WithClock sets a custom clock implementation.
|
||||||
|
func WithClock(clock clockpkg.Clock) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if clock != nil {
|
||||||
|
s.clock = clock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCalculator sets a custom calculator implementation.
|
||||||
|
func WithCalculator(calculator Calculator) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if calculator != nil {
|
||||||
|
s.calculator = calculator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOracleClient wires an FX oracle client for FX trigger evaluations.
|
||||||
|
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.oracle = oracle
|
||||||
|
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
||||||
|
qc.oracle = oracle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
322
api/billing/fees/internal/service/fees/service.go
Normal file
322
api/billing/fees/internal/service/fees/service.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
storage storage.Repository
|
||||||
|
producer msg.Producer
|
||||||
|
clock clockpkg.Clock
|
||||||
|
calculator Calculator
|
||||||
|
oracle oracleclient.Client
|
||||||
|
feesv1.UnimplementedFeeEngineServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||||
|
svc := &Service{
|
||||||
|
logger: logger.Named("fees"),
|
||||||
|
storage: repo,
|
||||||
|
producer: producer,
|
||||||
|
clock: clockpkg.NewSystem(),
|
||||||
|
}
|
||||||
|
initMetrics()
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.clock == nil {
|
||||||
|
svc.clock = clockpkg.NewSystem()
|
||||||
|
}
|
||||||
|
if svc.calculator == nil {
|
||||||
|
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
|
feesv1.RegisterFeeEngineServer(reg, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||||
|
start := s.clock.Now()
|
||||||
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
|
if req != nil && req.GetIntent() != nil {
|
||||||
|
trigger = req.GetIntent().GetTrigger()
|
||||||
|
}
|
||||||
|
var fxUsed bool
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
}
|
||||||
|
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = s.validateQuoteRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||||
|
if parseErr != nil {
|
||||||
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
|
||||||
|
if computeErr != nil {
|
||||||
|
err = computeErr
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &feesv1.QuoteFeesResponse{
|
||||||
|
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fx,
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
||||||
|
start := s.clock.Now()
|
||||||
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
|
if req != nil && req.GetIntent() != nil {
|
||||||
|
trigger = req.GetIntent().GetTrigger()
|
||||||
|
}
|
||||||
|
var fxUsed bool
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
}
|
||||||
|
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := s.clock.Now()
|
||||||
|
|
||||||
|
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||||
|
if parseErr != nil {
|
||||||
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
|
||||||
|
if computeErr != nil {
|
||||||
|
err = computeErr
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := req.GetTtlMs()
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 60000
|
||||||
|
}
|
||||||
|
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||||
|
|
||||||
|
payload := feeQuoteTokenPayload{
|
||||||
|
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||||
|
Intent: req.GetIntent(),
|
||||||
|
ExpiresAtUnixMs: expiresAt.UnixMilli(),
|
||||||
|
Trace: req.GetMeta().GetTrace(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
if token, err = encodeTokenPayload(payload); err != nil {
|
||||||
|
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
|
||||||
|
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &feesv1.PrecomputeFeesResponse{
|
||||||
|
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||||
|
FeeQuoteToken: token,
|
||||||
|
ExpiresAt: timestamppb.New(expiresAt),
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fx,
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
||||||
|
start := s.clock.Now()
|
||||||
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
|
var fxUsed bool
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
if !resp.GetValid() {
|
||||||
|
statusLabel = "invalid"
|
||||||
|
}
|
||||||
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
if resp.GetIntent() != nil {
|
||||||
|
trigger = resp.GetIntent().GetTrigger()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
}()
|
||||||
|
|
||||||
|
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||||
|
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := s.clock.Now()
|
||||||
|
|
||||||
|
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||||
|
if decodeErr != nil {
|
||||||
|
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
||||||
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger = payload.Intent.GetTrigger()
|
||||||
|
|
||||||
|
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||||
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
||||||
|
if parseErr != nil {
|
||||||
|
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
||||||
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
|
||||||
|
if computeErr != nil {
|
||||||
|
err = computeErr
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &feesv1.ValidateFeeTokenResponse{
|
||||||
|
Meta: &feesv1.ResponseMeta{Trace: payload.Trace},
|
||||||
|
Valid: true,
|
||||||
|
Intent: payload.Intent,
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fx,
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "request is required")
|
||||||
|
}
|
||||||
|
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
||||||
|
}
|
||||||
|
if req.GetIntent() == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "intent is required")
|
||||||
|
}
|
||||||
|
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
|
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
||||||
|
}
|
||||||
|
if req.GetIntent().GetBaseAmount() == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "request is required")
|
||||||
|
}
|
||||||
|
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) computeQuote(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
||||||
|
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
||||||
|
bookedAt := now
|
||||||
|
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
||||||
|
bookedAt = intent.GetBookedAt().AsTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||||
|
}
|
||||||
|
s.logger.Warn("failed to load active fee plan", zap.Error(err))
|
||||||
|
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||||
|
if calcErr != nil {
|
||||||
|
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
|
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||||
|
}
|
||||||
|
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||||
|
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Lines, result.Applied, result.FxUsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type feeQuoteTokenPayload struct {
|
||||||
|
OrganizationRef string `json:"organization_ref"`
|
||||||
|
Intent *feesv1.Intent `json:"intent"`
|
||||||
|
ExpiresAtUnixMs int64 `json:"expires_at_unix_ms"`
|
||||||
|
Trace *tracev1.TraceContext `json:"trace,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.Internal("fees: failed to serialize token payload")
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
||||||
|
var payload feeQuoteTokenPayload
|
||||||
|
data, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
476
api/billing/fees/internal/service/fees/service_test.go
Normal file
476
api/billing/fees/internal/service/fees/service_test.go
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||||
|
orgRef := primitive.NewObjectID()
|
||||||
|
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "capture_default",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 10,
|
||||||
|
Percentage: "0.029",
|
||||||
|
FixedAmount: "0.30",
|
||||||
|
LedgerAccountRef: "acct:fees",
|
||||||
|
LineType: "fee",
|
||||||
|
EntrySide: "credit",
|
||||||
|
Rounding: "half_up",
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"scale": "2",
|
||||||
|
"tax_code": "VAT",
|
||||||
|
"tax_rate": "0.20",
|
||||||
|
},
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.SetID(primitive.NewObjectID())
|
||||||
|
plan.SetOrganizationRef(orgRef)
|
||||||
|
|
||||||
|
service := NewService(
|
||||||
|
zap.NewNop(),
|
||||||
|
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||||
|
noopProducer{},
|
||||||
|
WithClock(fixedClock{now: now}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := &feesv1.QuoteFeesRequest{
|
||||||
|
Meta: &feesv1.RequestMeta{
|
||||||
|
OrganizationRef: orgRef.Hex(),
|
||||||
|
Trace: &tracev1.TraceContext{
|
||||||
|
TraceRef: "trace-capture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Intent: &feesv1.Intent{
|
||||||
|
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Amount: "100.00",
|
||||||
|
Currency: "USD",
|
||||||
|
},
|
||||||
|
BookedAt: timestamppb.New(now),
|
||||||
|
Attributes: map[string]string{"channel": "card"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.QuoteFees(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.GetMeta().GetTrace().GetTraceRef() != "trace-capture" {
|
||||||
|
t.Fatalf("expected trace_ref to round-trip, got %q", resp.GetMeta().GetTrace().GetTraceRef())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.GetLines()) != 1 {
|
||||||
|
t.Fatalf("expected 1 derived line, got %d", len(resp.GetLines()))
|
||||||
|
}
|
||||||
|
|
||||||
|
line := resp.GetLines()[0]
|
||||||
|
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
||||||
|
t.Fatalf("expected fee amount 3.20, got %s", got)
|
||||||
|
}
|
||||||
|
if line.GetMoney().GetCurrency() != "USD" {
|
||||||
|
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
||||||
|
}
|
||||||
|
if line.GetLedgerAccountRef() != "acct:fees" {
|
||||||
|
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
|
||||||
|
}
|
||||||
|
if meta := line.GetMeta(); meta["fee_rule_id"] != "capture_default" || meta["fee_plan_id"] != plan.GetID().Hex() || meta["tax_code"] != "VAT" {
|
||||||
|
t.Fatalf("unexpected derived line metadata: %#v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.GetApplied()) != 1 {
|
||||||
|
t.Fatalf("expected 1 applied rule, got %d", len(resp.GetApplied()))
|
||||||
|
}
|
||||||
|
|
||||||
|
applied := resp.GetApplied()[0]
|
||||||
|
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
|
||||||
|
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
||||||
|
}
|
||||||
|
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
||||||
|
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
||||||
|
}
|
||||||
|
if applied.GetParameters()["scale"] != "2" {
|
||||||
|
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||||
|
orgRef := primitive.NewObjectID()
|
||||||
|
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-24 * time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "base",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 1,
|
||||||
|
Percentage: "0.10",
|
||||||
|
LedgerAccountRef: "acct:base",
|
||||||
|
Metadata: map[string]string{"scale": "2"},
|
||||||
|
Rounding: "half_even",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RuleID: "future",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 2,
|
||||||
|
Percentage: "0.50",
|
||||||
|
LedgerAccountRef: "acct:future",
|
||||||
|
Metadata: map[string]string{"scale": "2"},
|
||||||
|
Rounding: "half_even",
|
||||||
|
EffectiveFrom: now.Add(time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RuleID: "attr",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 3,
|
||||||
|
Percentage: "0.30",
|
||||||
|
LedgerAccountRef: "acct:attr",
|
||||||
|
Metadata: map[string]string{"scale": "2"},
|
||||||
|
AppliesTo: map[string]string{"region": "eu"},
|
||||||
|
Rounding: "half_even",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.SetID(primitive.NewObjectID())
|
||||||
|
plan.SetOrganizationRef(orgRef)
|
||||||
|
|
||||||
|
service := NewService(
|
||||||
|
zap.NewNop(),
|
||||||
|
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||||
|
noopProducer{},
|
||||||
|
WithClock(fixedClock{now: now}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := &feesv1.QuoteFeesRequest{
|
||||||
|
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||||
|
Intent: &feesv1.Intent{
|
||||||
|
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Amount: "50.00",
|
||||||
|
Currency: "EUR",
|
||||||
|
},
|
||||||
|
BookedAt: timestamppb.New(now),
|
||||||
|
Attributes: map[string]string{"region": "us"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.QuoteFees(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.GetLines()) != 1 {
|
||||||
|
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
||||||
|
}
|
||||||
|
line := resp.GetLines()[0]
|
||||||
|
if line.GetLedgerAccountRef() != "acct:base" {
|
||||||
|
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
||||||
|
}
|
||||||
|
if line.GetMoney().GetAmount() != "5.00" {
|
||||||
|
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
orgRef := primitive.NewObjectID()
|
||||||
|
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "round_down",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 1,
|
||||||
|
FixedAmount: "0.015",
|
||||||
|
LedgerAccountRef: "acct:round",
|
||||||
|
Metadata: map[string]string{"scale": "2"},
|
||||||
|
Rounding: "down",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.SetID(primitive.NewObjectID())
|
||||||
|
plan.SetOrganizationRef(orgRef)
|
||||||
|
|
||||||
|
service := NewService(
|
||||||
|
zap.NewNop(),
|
||||||
|
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||||
|
noopProducer{},
|
||||||
|
WithClock(fixedClock{now: now}),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := &feesv1.QuoteFeesRequest{
|
||||||
|
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||||
|
Intent: &feesv1.Intent{
|
||||||
|
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Amount: "1.00",
|
||||||
|
Currency: "USD",
|
||||||
|
},
|
||||||
|
BookedAt: timestamppb.New(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.QuoteFees(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.GetLines()) != 1 {
|
||||||
|
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
||||||
|
}
|
||||||
|
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
||||||
|
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||||
|
orgRef := primitive.NewObjectID()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
}
|
||||||
|
plan.SetID(primitive.NewObjectID())
|
||||||
|
plan.SetOrganizationRef(orgRef)
|
||||||
|
|
||||||
|
result := &CalculationResult{
|
||||||
|
Lines: []*feesv1.DerivedPostingLine{
|
||||||
|
{
|
||||||
|
LedgerAccountRef: "acct:stub",
|
||||||
|
Money: &moneyv1.Money{
|
||||||
|
Amount: "1.23",
|
||||||
|
Currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Applied: []*feesv1.AppliedRule{
|
||||||
|
{RuleId: "stub"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
calc := &stubCalculator{result: result}
|
||||||
|
|
||||||
|
service := NewService(
|
||||||
|
zap.NewNop(),
|
||||||
|
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||||
|
noopProducer{},
|
||||||
|
WithClock(fixedClock{now: now}),
|
||||||
|
WithCalculator(calc),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
|
||||||
|
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||||
|
Intent: &feesv1.Intent{
|
||||||
|
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Amount: "10.00",
|
||||||
|
Currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !calc.called {
|
||||||
|
t.Fatalf("expected calculator to be invoked")
|
||||||
|
}
|
||||||
|
if calc.gotPlan != plan {
|
||||||
|
t.Fatalf("expected calculator to receive plan pointer")
|
||||||
|
}
|
||||||
|
if len(resp.GetLines()) != len(result.Lines) {
|
||||||
|
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
||||||
|
}
|
||||||
|
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
||||||
|
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||||
|
orgRef := primitive.NewObjectID()
|
||||||
|
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "fx_mark_up",
|
||||||
|
Trigger: model.TriggerFXConversion,
|
||||||
|
Priority: 1,
|
||||||
|
Percentage: "0.03",
|
||||||
|
LedgerAccountRef: "acct:fx",
|
||||||
|
Metadata: map[string]string{"scale": "2"},
|
||||||
|
Rounding: "half_even",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.SetID(primitive.NewObjectID())
|
||||||
|
plan.SetOrganizationRef(orgRef)
|
||||||
|
|
||||||
|
fakeOracle := &oracleclient.Fake{
|
||||||
|
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||||
|
return &oracleclient.RateSnapshot{
|
||||||
|
Pair: req.Pair,
|
||||||
|
Mid: "1.2300",
|
||||||
|
SpreadBps: "12",
|
||||||
|
Provider: "TestProvider",
|
||||||
|
RateRef: "rate-ref-123",
|
||||||
|
AsOf: now.Add(-2 * time.Minute),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
service := NewService(
|
||||||
|
zap.NewNop(),
|
||||||
|
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||||
|
noopProducer{},
|
||||||
|
WithClock(fixedClock{now: now}),
|
||||||
|
WithOracleClient(fakeOracle),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
|
||||||
|
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||||
|
Intent: &feesv1.Intent{
|
||||||
|
Trigger: feesv1.Trigger_TRIGGER_FX_CONVERSION,
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Amount: "100.00",
|
||||||
|
Currency: "USD",
|
||||||
|
},
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"fx_base_currency": "USD",
|
||||||
|
"fx_quote_currency": "EUR",
|
||||||
|
"fx_provider": "TestProvider",
|
||||||
|
"fx_side": "buy_base",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.GetFxUsed() == nil {
|
||||||
|
t.Fatalf("expected FxUsed to be populated")
|
||||||
|
}
|
||||||
|
fx := resp.GetFxUsed()
|
||||||
|
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
||||||
|
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
||||||
|
}
|
||||||
|
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
||||||
|
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubRepository struct {
|
||||||
|
plans storage.PlansStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRepository) Ping(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRepository) Plans() storage.PlansStore {
|
||||||
|
return s.plans
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubPlansStore struct {
|
||||||
|
plan *model.FeePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if s.plan == nil {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if s.plan.GetOrganizationRef() != orgRef {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if !s.plan.Active {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
return s.plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopProducer struct{}
|
||||||
|
|
||||||
|
func (noopProducer) SendMessage(me.Envelope) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fixedClock) Now() time.Time {
|
||||||
|
return f.now
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubCalculator struct {
|
||||||
|
result *CalculationResult
|
||||||
|
err error
|
||||||
|
called bool
|
||||||
|
gotPlan *model.FeePlan
|
||||||
|
bookedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
||||||
|
s.called = true
|
||||||
|
s.gotPlan = plan
|
||||||
|
s.bookedAt = bookedAt
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return s.result, nil
|
||||||
|
}
|
||||||
17
api/billing/fees/main.go
Normal file
17
api/billing/fees/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/billing/fees/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)
|
||||||
|
}
|
||||||
62
api/billing/fees/storage/model/plan.go
Normal file
62
api/billing/fees/storage/model/plan.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FeePlansCollection = "fee_plans"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger represents the event that causes a fee rule to apply.
|
||||||
|
type Trigger string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TriggerUnspecified Trigger = "unspecified"
|
||||||
|
TriggerCapture Trigger = "capture"
|
||||||
|
TriggerRefund Trigger = "refund"
|
||||||
|
TriggerDispute Trigger = "dispute"
|
||||||
|
TriggerPayout Trigger = "payout"
|
||||||
|
TriggerFXConversion Trigger = "fx_conversion"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeePlan describes a collection of fee rules for an organisation.
|
||||||
|
type FeePlan struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||||
|
model.Describable `bson:",inline" json:",inline"`
|
||||||
|
Active bool `bson:"active" json:"active"`
|
||||||
|
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||||
|
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||||
|
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||||
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*FeePlan) Collection() string {
|
||||||
|
return FeePlansCollection
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeeRule represents a single pricing rule within a plan.
|
||||||
|
type FeeRule struct {
|
||||||
|
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||||
|
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||||
|
Priority int `bson:"priority" json:"priority"`
|
||||||
|
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||||
|
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||||
|
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||||
|
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||||
|
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||||
|
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||||
|
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||||
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
|
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||||
|
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||||
|
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||||
|
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||||
|
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||||
|
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||||
|
}
|
||||||
69
api/billing/fees/storage/mongo/repository.go
Normal file
69
api/billing/fees/storage/mongo/repository.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/mongo/store"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
conn *db.MongoConnection
|
||||||
|
db *mongo.Database
|
||||||
|
|
||||||
|
plans storage.PlansStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a repository backed by MongoDB for the billing fees service.
|
||||||
|
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||||
|
if conn == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo connection is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := conn.Client()
|
||||||
|
if client == nil {
|
||||||
|
return nil, merrors.Internal("mongo client not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
database := conn.Database()
|
||||||
|
result := &Store{
|
||||||
|
logger: logger.Named("storage").Named("mongo"),
|
||||||
|
conn: conn,
|
||||||
|
db: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := result.Ping(ctx); err != nil {
|
||||||
|
result.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plansStore, err := store.NewPlans(result.logger, database)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("failed to initialise plans store", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.plans = plansStore
|
||||||
|
|
||||||
|
result.logger.Info("Billing fees MongoDB storage initialised")
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
return s.conn.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Plans() storage.PlansStore {
|
||||||
|
return s.plans
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Repository = (*Store)(nil)
|
||||||
144
api/billing/fees/storage/mongo/store/plans.go
Normal file
144
api/billing/fees/storage/mongo/store/plans.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
m "github.com/tech/sendico/pkg/model"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type plansStore struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlans constructs a Mongo-backed PlansStore.
|
||||||
|
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
||||||
|
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
|
||||||
|
|
||||||
|
// Index for organisation lookups.
|
||||||
|
orgIndex := &ri.Definition{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||||
|
{Field: "effectiveFrom", Sort: ri.Desc},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.CreateIndex(orgIndex); err != nil {
|
||||||
|
logger.Error("failed to ensure fee plan organization index", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique index for plan versions (per organisation + effectiveFrom).
|
||||||
|
uniqueIndex := &ri.Definition{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||||
|
{Field: "effectiveFrom", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
Unique: true,
|
||||||
|
}
|
||||||
|
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||||
|
logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &plansStore{
|
||||||
|
logger: logger.Named("plans"),
|
||||||
|
repo: repo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||||
|
if plan == nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||||
|
}
|
||||||
|
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
return storage.ErrDuplicateFeePlan
|
||||||
|
}
|
||||||
|
p.logger.Warn("failed to create fee plan", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
||||||
|
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||||
|
}
|
||||||
|
if err := p.repo.Update(ctx, plan); err != nil {
|
||||||
|
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) {
|
||||||
|
if planRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
||||||
|
}
|
||||||
|
result := &model.FeePlan{}
|
||||||
|
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if orgRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(1)
|
||||||
|
query := repository.Query().
|
||||||
|
Filter(repository.OrgField(), orgRef).
|
||||||
|
Filter(repository.Field("active"), true).
|
||||||
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||||
|
Sort(repository.Field("effectiveFrom"), false).
|
||||||
|
Limit(&limit)
|
||||||
|
|
||||||
|
query = query.And(
|
||||||
|
repository.Query().Or(
|
||||||
|
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||||
|
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var plan *model.FeePlan
|
||||||
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
|
target := &model.FeePlan{}
|
||||||
|
if err := cursor.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plan = target
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan == nil {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*plansStore)(nil)
|
||||||
36
api/billing/fees/storage/storage.go
Normal file
36
api/billing/fees/storage/storage.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageError string
|
||||||
|
|
||||||
|
func (e storageError) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrFeePlanNotFound indicates that a requested fee plan does not exist.
|
||||||
|
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||||
|
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||||
|
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository defines the root storage contract for the fees service.
|
||||||
|
type Repository interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
Plans() PlansStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlansStore exposes persistence operations for fee plans.
|
||||||
|
type PlansStore interface {
|
||||||
|
Create(ctx context.Context, plan *model.FeePlan) error
|
||||||
|
Update(ctx context.Context, plan *model.FeePlan) error
|
||||||
|
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
||||||
|
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
|
}
|
||||||
32
api/chain/gateway/.air.toml
Normal file
32
api/chain/gateway/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Config file for Air in TOML format
|
||||||
|
|
||||||
|
root = "./../.."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/chain/gateway/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.BuildDate=$(date)'\""
|
||||||
|
bin = "./app"
|
||||||
|
full_bin = "./app --debug --config.file=config.yml"
|
||||||
|
include_ext = ["go", "yaml", "yml"]
|
||||||
|
exclude_dir = ["chain/gateway/tmp", "pkg/.git", "chain/gateway/env"]
|
||||||
|
exclude_regex = ["_test\\.go"]
|
||||||
|
exclude_unchanged = true
|
||||||
|
follow_symlink = true
|
||||||
|
log = "air.log"
|
||||||
|
delay = 0
|
||||||
|
stop_on_error = true
|
||||||
|
send_interrupt = true
|
||||||
|
kill_delay = 500
|
||||||
|
args_bin = []
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
main = "magenta"
|
||||||
|
watcher = "cyan"
|
||||||
|
build = "yellow"
|
||||||
|
runner = "green"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
3
api/chain/gateway/.gitignore
vendored
Normal file
3
api/chain/gateway/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
148
api/chain/gateway/client/client.go
Normal file
148
api/chain/gateway/client/client.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client exposes typed helpers around the chain gateway gRPC API.
|
||||||
|
type Client interface {
|
||||||
|
CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error)
|
||||||
|
GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error)
|
||||||
|
ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error)
|
||||||
|
GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error)
|
||||||
|
SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error)
|
||||||
|
GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error)
|
||||||
|
ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error)
|
||||||
|
EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type grpcGatewayClient interface {
|
||||||
|
CreateManagedWallet(ctx context.Context, in *gatewayv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*gatewayv1.CreateManagedWalletResponse, error)
|
||||||
|
GetManagedWallet(ctx context.Context, in *gatewayv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*gatewayv1.GetManagedWalletResponse, error)
|
||||||
|
ListManagedWallets(ctx context.Context, in *gatewayv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*gatewayv1.ListManagedWalletsResponse, error)
|
||||||
|
GetWalletBalance(ctx context.Context, in *gatewayv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*gatewayv1.GetWalletBalanceResponse, error)
|
||||||
|
SubmitTransfer(ctx context.Context, in *gatewayv1.SubmitTransferRequest, opts ...grpc.CallOption) (*gatewayv1.SubmitTransferResponse, error)
|
||||||
|
GetTransfer(ctx context.Context, in *gatewayv1.GetTransferRequest, opts ...grpc.CallOption) (*gatewayv1.GetTransferResponse, error)
|
||||||
|
ListTransfers(ctx context.Context, in *gatewayv1.ListTransfersRequest, opts ...grpc.CallOption) (*gatewayv1.ListTransfersResponse, error)
|
||||||
|
EstimateTransferFee(ctx context.Context, in *gatewayv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*gatewayv1.EstimateTransferFeeResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chainGatewayClient struct {
|
||||||
|
cfg Config
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
client grpcGatewayClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// New dials the chain gateway endpoint and returns a ready client.
|
||||||
|
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
||||||
|
cfg.setDefaults()
|
||||||
|
if strings.TrimSpace(cfg.Address) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: address is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||||
|
dialOpts = append(dialOpts, opts...)
|
||||||
|
|
||||||
|
if cfg.Insecure {
|
||||||
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
} else {
|
||||||
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal(fmt.Sprintf("chain-gateway: dial %s: %s", cfg.Address, err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &chainGatewayClient{
|
||||||
|
cfg: cfg,
|
||||||
|
conn: conn,
|
||||||
|
client: gatewayv1.NewChainGatewayServiceClient(conn),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithClient injects a pre-built gateway client (useful for tests).
|
||||||
|
func NewWithClient(cfg Config, gc grpcGatewayClient) Client {
|
||||||
|
cfg.setDefaults()
|
||||||
|
return &chainGatewayClient{
|
||||||
|
cfg: cfg,
|
||||||
|
client: gc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) Close() error {
|
||||||
|
if c.conn != nil {
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.CreateManagedWallet(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.GetManagedWallet(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.ListManagedWallets(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.GetWalletBalance(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.SubmitTransfer(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.GetTransfer(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.ListTransfers(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.EstimateTransferFee(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
timeout := c.cfg.CallTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, timeout)
|
||||||
|
}
|
||||||
20
api/chain/gateway/client/config.go
Normal file
20
api/chain/gateway/client/config.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Config captures connection settings for the chain gateway gRPC service.
|
||||||
|
type Config struct {
|
||||||
|
Address string
|
||||||
|
DialTimeout time.Duration
|
||||||
|
CallTimeout time.Duration
|
||||||
|
Insecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) setDefaults() {
|
||||||
|
if c.DialTimeout <= 0 {
|
||||||
|
c.DialTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if c.CallTimeout <= 0 {
|
||||||
|
c.CallTimeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
83
api/chain/gateway/client/fake.go
Normal file
83
api/chain/gateway/client/fake.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fake implements Client for tests.
|
||||||
|
type Fake struct {
|
||||||
|
CreateManagedWalletFn func(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error)
|
||||||
|
GetManagedWalletFn func(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error)
|
||||||
|
ListManagedWalletsFn func(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error)
|
||||||
|
GetWalletBalanceFn func(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error)
|
||||||
|
SubmitTransferFn func(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error)
|
||||||
|
GetTransferFn func(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error)
|
||||||
|
ListTransfersFn func(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error)
|
||||||
|
EstimateTransferFeeFn func(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error)
|
||||||
|
CloseFn func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
|
||||||
|
if f.CreateManagedWalletFn != nil {
|
||||||
|
return f.CreateManagedWalletFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.CreateManagedWalletResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
|
||||||
|
if f.GetManagedWalletFn != nil {
|
||||||
|
return f.GetManagedWalletFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.GetManagedWalletResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
|
||||||
|
if f.ListManagedWalletsFn != nil {
|
||||||
|
return f.ListManagedWalletsFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.ListManagedWalletsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
|
||||||
|
if f.GetWalletBalanceFn != nil {
|
||||||
|
return f.GetWalletBalanceFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.GetWalletBalanceResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
|
||||||
|
if f.SubmitTransferFn != nil {
|
||||||
|
return f.SubmitTransferFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.SubmitTransferResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
|
||||||
|
if f.GetTransferFn != nil {
|
||||||
|
return f.GetTransferFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.GetTransferResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
|
||||||
|
if f.ListTransfersFn != nil {
|
||||||
|
return f.ListTransfersFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.ListTransfersResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
|
||||||
|
if f.EstimateTransferFeeFn != nil {
|
||||||
|
return f.EstimateTransferFeeFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &gatewayv1.EstimateTransferFeeResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Close() error {
|
||||||
|
if f.CloseFn != nil {
|
||||||
|
return f.CloseFn()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
57
api/chain/gateway/config.yml
Normal file
57
api/chain/gateway/config.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50070"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9403"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: CHAIN_GATEWAY_MONGO_HOST
|
||||||
|
port_env: CHAIN_GATEWAY_MONGO_PORT
|
||||||
|
database_env: CHAIN_GATEWAY_MONGO_DATABASE
|
||||||
|
user_env: CHAIN_GATEWAY_MONGO_USER
|
||||||
|
password_env: CHAIN_GATEWAY_MONGO_PASSWORD
|
||||||
|
auth_source_env: CHAIN_GATEWAY_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: CHAIN_GATEWAY_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
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: Chain Gateway Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
|
chains:
|
||||||
|
- name: arbitrum_one
|
||||||
|
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
||||||
|
tokens:
|
||||||
|
- symbol: USDC
|
||||||
|
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
|
||||||
|
- symbol: USDT
|
||||||
|
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
|
||||||
|
|
||||||
|
service_wallet:
|
||||||
|
chain: arbitrum_one
|
||||||
|
address: "0xSERVICE_WALLET_ADDRESS"
|
||||||
|
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
||||||
|
|
||||||
|
key_management:
|
||||||
|
driver: vault
|
||||||
|
settings:
|
||||||
|
address: "http://vault:8200"
|
||||||
|
token_env: CHAIN_GATEWAY_VAULT_TOKEN
|
||||||
|
namespace: ""
|
||||||
|
mount_path: secret
|
||||||
|
key_prefix: chain/gateway/wallets
|
||||||
90
api/chain/gateway/go.mod
Normal file
90
api/chain/gateway/go.mod
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
module github.com/tech/sendico/chain/gateway
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||||
|
github.com/ethereum/go-ethereum v1.16.7
|
||||||
|
github.com/hashicorp/vault/api v1.22.0
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/shopspring/decimal v1.4.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
google.golang.org/grpc v1.76.0
|
||||||
|
google.golang.org/protobuf v1.36.10
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251106012722-c7be33e82a11 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.3 // indirect
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
|
github.com/casbin/casbin/v2 v2.132.0 // indirect
|
||||||
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/consensys/gnark-crypto v0.19.2 // indirect
|
||||||
|
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||||
|
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
|
||||||
|
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||||
|
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||||
|
github.com/holiman/uint256 v1.3.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.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.47.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.2 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
|
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||||
|
github.com/supranational/blst v0.3.16 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||||
|
)
|
||||||
379
api/chain/gateway/go.sum
Normal file
379
api/chain/gateway/go.sum
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
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/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||||
|
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/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251106012722-c7be33e82a11 h1:cP8UbFCldZ6uVbZnI3/EI4FSdO9NaYnx4hY+tyW6FbU=
|
||||||
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251106012722-c7be33e82a11/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
|
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.3 h1:Bte86SlO3lwPQqww+7BE9ZuUCKIjfqnG5jtEyqA9y9Y=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
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.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||||
|
github.com/casbin/casbin/v2 v2.132.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/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
|
||||||
|
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
|
||||||
|
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
|
||||||
|
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
|
||||||
|
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
|
||||||
|
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
|
||||||
|
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
|
||||||
|
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
|
||||||
|
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
|
||||||
|
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||||
|
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
|
||||||
|
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||||
|
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||||
|
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||||
|
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/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
|
||||||
|
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||||
|
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||||
|
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||||
|
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/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
|
||||||
|
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
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/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||||
|
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||||
|
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
|
||||||
|
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
|
||||||
|
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
|
||||||
|
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
|
||||||
|
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
|
||||||
|
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||||
|
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||||
|
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||||
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||||
|
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||||
|
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||||
|
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||||
|
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-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
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.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
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/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||||
|
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
|
||||||
|
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||||
|
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||||
|
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||||
|
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
|
||||||
|
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
|
||||||
|
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330=
|
||||||
|
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg=
|
||||||
|
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
|
||||||
|
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
|
||||||
|
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||||
|
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||||
|
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||||
|
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||||
|
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||||
|
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||||
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||||
|
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||||
|
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/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
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/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
|
||||||
|
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
|
||||||
|
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.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||||
|
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||||
|
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||||
|
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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||||
|
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/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||||
|
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||||
|
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||||
|
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||||
|
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
||||||
|
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
||||||
|
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
||||||
|
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||||
|
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
||||||
|
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||||
|
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.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||||
|
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
|
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
|
||||||
|
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||||
|
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||||
|
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||||
|
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||||
|
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/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/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/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||||
|
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||||
|
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
|
||||||
|
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||||
|
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/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||||
|
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||||
|
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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
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/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.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.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
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-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
27
api/chain/gateway/internal/appversion/version.go
Normal file
27
api/chain/gateway/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 {
|
||||||
|
info := version.Info{
|
||||||
|
Program: "MeetX Connectica Chain Gateway Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
13
api/chain/gateway/internal/keymanager/config.go
Normal file
13
api/chain/gateway/internal/keymanager/config.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package keymanager
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/model"
|
||||||
|
|
||||||
|
// Driver identifies the key management backend implementation.
|
||||||
|
type Driver string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriverVault Driver = "vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents a configured key manager driver with arbitrary settings.
|
||||||
|
type Config = model.DriverConfig[Driver]
|
||||||
23
api/chain/gateway/internal/keymanager/keymanager.go
Normal file
23
api/chain/gateway/internal/keymanager/keymanager.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package keymanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManagedWalletKey captures information returned after provisioning a managed wallet key.
|
||||||
|
type ManagedWalletKey struct {
|
||||||
|
KeyID string
|
||||||
|
Address string
|
||||||
|
PublicKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager defines the contract for managing managed wallet keys.
|
||||||
|
type Manager interface {
|
||||||
|
// CreateManagedWalletKey provisions a new managed wallet key for the provided wallet reference and network.
|
||||||
|
CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*ManagedWalletKey, error)
|
||||||
|
// SignTransaction signs the provided transaction using the identified key material.
|
||||||
|
SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
|
||||||
|
}
|
||||||
269
api/chain/gateway/internal/keymanager/vault/manager.go
Normal file
269
api/chain/gateway/internal/keymanager/vault/manager.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
|
"github.com/hashicorp/vault/api"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config describes how to connect to Vault for managed wallet keys.
|
||||||
|
type Config struct {
|
||||||
|
Address string `mapstructure:"address"`
|
||||||
|
TokenEnv string `mapstructure:"token_env"`
|
||||||
|
Namespace string `mapstructure:"namespace"`
|
||||||
|
MountPath string `mapstructure:"mount_path"`
|
||||||
|
KeyPrefix string `mapstructure:"key_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager implements the keymanager.Manager contract backed by HashiCorp Vault.
|
||||||
|
type Manager struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
client *api.Client
|
||||||
|
store *api.KVv2
|
||||||
|
keyPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Vault-backed key manager.
|
||||||
|
func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: logger is required")
|
||||||
|
}
|
||||||
|
address := strings.TrimSpace(cfg.Address)
|
||||||
|
if address == "" {
|
||||||
|
logger.Error("vault address missing")
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: address is required")
|
||||||
|
}
|
||||||
|
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
|
||||||
|
if tokenEnv == "" {
|
||||||
|
logger.Error("vault token env missing")
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||||
|
if token == "" {
|
||||||
|
logger.Error("vault token env not set", zap.String("env", tokenEnv))
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set")
|
||||||
|
}
|
||||||
|
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
||||||
|
if mountPath == "" {
|
||||||
|
logger.Error("vault mount path missing")
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
||||||
|
}
|
||||||
|
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||||
|
if keyPrefix == "" {
|
||||||
|
keyPrefix = "chain/gateway/wallets"
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCfg := api.DefaultConfig()
|
||||||
|
clientCfg.Address = address
|
||||||
|
|
||||||
|
client, err := api.NewClient(clientCfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to create vault client", zap.Error(err))
|
||||||
|
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
||||||
|
}
|
||||||
|
client.SetToken(token)
|
||||||
|
if ns := strings.TrimSpace(cfg.Namespace); ns != "" {
|
||||||
|
client.SetNamespace(ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
kv := client.KVv2(mountPath)
|
||||||
|
|
||||||
|
return &Manager{
|
||||||
|
logger: logger.Named("vault"),
|
||||||
|
client: client,
|
||||||
|
store: kv,
|
||||||
|
keyPrefix: keyPrefix,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if strings.TrimSpace(walletRef) == "" {
|
||||||
|
m.logger.Warn("walletRef missing for managed key creation", zap.String("network", network))
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(network) == "" {
|
||||||
|
m.logger.Warn("network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
privateKeyBytes := crypto.FromECDSA(privateKey)
|
||||||
|
publicKey := privateKey.PublicKey
|
||||||
|
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
|
||||||
|
publicKeyHex := hex.EncodeToString(publicKeyBytes)
|
||||||
|
address := crypto.PubkeyToAddress(publicKey).Hex()
|
||||||
|
|
||||||
|
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||||
|
zeroBytes(privateKeyBytes)
|
||||||
|
zeroBytes(publicKeyBytes)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
zeroBytes(privateKeyBytes)
|
||||||
|
zeroBytes(publicKeyBytes)
|
||||||
|
|
||||||
|
m.logger.Info("managed wallet key created",
|
||||||
|
zap.String("wallet_ref", walletRef),
|
||||||
|
zap.String("network", network),
|
||||||
|
zap.String("address", strings.ToLower(address)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &keymanager.ManagedWalletKey{
|
||||||
|
KeyID: m.buildKeyID(network, walletRef),
|
||||||
|
Address: strings.ToLower(address),
|
||||||
|
PublicKey: publicKeyHex,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error {
|
||||||
|
secretPath := m.buildKeyID(network, walletRef)
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"private_key": hex.EncodeToString(privateKey),
|
||||||
|
"public_key": hex.EncodeToString(publicKey),
|
||||||
|
"address": strings.ToLower(address),
|
||||||
|
"network": strings.ToLower(network),
|
||||||
|
}
|
||||||
|
if _, err := m.store.Put(ctx, secretPath, payload); err != nil {
|
||||||
|
return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) buildKeyID(network, walletRef string) string {
|
||||||
|
net := strings.Trim(strings.ToLower(network), "/")
|
||||||
|
return path.Join(m.keyPrefix, net, walletRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if strings.TrimSpace(keyID) == "" {
|
||||||
|
m.logger.Warn("signing failed: empty key id")
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
m.logger.Warn("signing failed: nil transaction", zap.String("key_id", keyID))
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
|
||||||
|
}
|
||||||
|
if chainID == nil {
|
||||||
|
m.logger.Warn("signing failed: nil chain id", zap.String("key_id", keyID))
|
||||||
|
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
material, err := m.loadKey(ctx, keyID)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
defer zeroBytes(keyBytes)
|
||||||
|
|
||||||
|
privateKey, err := crypto.ToECDSA(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
||||||
|
if err != nil {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
m.logger.Info("transaction signed with managed key",
|
||||||
|
zap.String("key_id", keyID),
|
||||||
|
zap.String("network", material.Network),
|
||||||
|
zap.String("tx_hash", signed.Hash().Hex()),
|
||||||
|
)
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyMaterial struct {
|
||||||
|
PrivateKey string
|
||||||
|
PublicKey string
|
||||||
|
Address string
|
||||||
|
Network string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) {
|
||||||
|
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
|
||||||
|
secret, err := m.store.Get(ctx, secretPath)
|
||||||
|
if err != nil {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
if secret == nil || secret.Data == nil {
|
||||||
|
m.logger.Warn("secret not found", zap.String("path", secretPath))
|
||||||
|
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
getString := func(key string) (string, error) {
|
||||||
|
val, ok := secret.Data[key]
|
||||||
|
if !ok {
|
||||||
|
m.logger.Warn("secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
||||||
|
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
|
||||||
|
}
|
||||||
|
str, ok := val.(string)
|
||||||
|
if !ok || strings.TrimSpace(str) == "" {
|
||||||
|
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 str, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := getString("private_key")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
publicKey, err := getString("public_key")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
address, err := getString("address")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
network, err := getString("network")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &keyMaterial{
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
PublicKey: publicKey,
|
||||||
|
Address: address,
|
||||||
|
Network: network,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroBytes(data []byte) {
|
||||||
|
for i := range data {
|
||||||
|
data[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ keymanager.Manager = (*Manager)(nil)
|
||||||
259
api/chain/gateway/internal/server/internal/serverimp.go
Normal file
259
api/chain/gateway/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||||
|
vaultmanager "github.com/tech/sendico/chain/gateway/internal/keymanager/vault"
|
||||||
|
gatewayservice "github.com/tech/sendico/chain/gateway/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
gatewaymongo "github.com/tech/sendico/chain/gateway/storage/mongo"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *config
|
||||||
|
app *grpcapp.App[storage.Repository]
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Chains []chainConfig `yaml:"chains"`
|
||||||
|
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
|
||||||
|
KeyManagement keymanager.Config `yaml:"key_management"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chainConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||||
|
ChainID uint64 `yaml:"chain_id"`
|
||||||
|
NativeToken string `yaml:"native_token"`
|
||||||
|
Tokens []tokenConfig `yaml:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceWalletConfig struct {
|
||||||
|
Chain string `yaml:"chain"`
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
AddressEnv string `yaml:"address_env"`
|
||||||
|
PrivateKeyEnv string `yaml:"private_key_env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenConfig struct {
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
Contract string `yaml:"contract"`
|
||||||
|
ContractEnv string `yaml:"contract_env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initialises the chain gateway server implementation.
|
||||||
|
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) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
|
return gatewaymongo.New(logger, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := i.logger.Named("config")
|
||||||
|
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||||
|
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
||||||
|
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
|
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
|
||||||
|
opts := []gatewayservice.Option{
|
||||||
|
gatewayservice.WithNetworks(networkConfigs),
|
||||||
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
|
gatewayservice.WithTransferExecutor(executor),
|
||||||
|
}
|
||||||
|
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "chain_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.app = app
|
||||||
|
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Config: &grpcapp.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.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50070",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayservice.Network {
|
||||||
|
result := make([]gatewayservice.Network, 0, len(chains))
|
||||||
|
for _, chain := range chains {
|
||||||
|
if strings.TrimSpace(chain.Name) == "" {
|
||||||
|
logger.Warn("skipping unnamed chain configuration")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
||||||
|
if rpcURL == "" {
|
||||||
|
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
||||||
|
}
|
||||||
|
contracts := make([]gatewayservice.TokenContract, 0, len(chain.Tokens))
|
||||||
|
for _, token := range chain.Tokens {
|
||||||
|
symbol := strings.TrimSpace(token.Symbol)
|
||||||
|
if symbol == "" {
|
||||||
|
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr := strings.TrimSpace(token.Contract)
|
||||||
|
env := strings.TrimSpace(token.ContractEnv)
|
||||||
|
if addr == "" && env != "" {
|
||||||
|
addr = strings.TrimSpace(os.Getenv(env))
|
||||||
|
}
|
||||||
|
if addr == "" {
|
||||||
|
if env != "" {
|
||||||
|
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
||||||
|
} else {
|
||||||
|
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contracts = append(contracts, gatewayservice.TokenContract{
|
||||||
|
Symbol: symbol,
|
||||||
|
ContractAddress: addr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, gatewayservice.Network{
|
||||||
|
Name: chain.Name,
|
||||||
|
RPCURL: rpcURL,
|
||||||
|
ChainID: chain.ChainID,
|
||||||
|
NativeToken: chain.NativeToken,
|
||||||
|
TokenConfigs: contracts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayservice.ServiceWallet {
|
||||||
|
address := strings.TrimSpace(cfg.Address)
|
||||||
|
if address == "" && cfg.AddressEnv != "" {
|
||||||
|
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey := strings.TrimSpace(os.Getenv(cfg.PrivateKeyEnv))
|
||||||
|
|
||||||
|
if address == "" {
|
||||||
|
if cfg.AddressEnv != "" {
|
||||||
|
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
||||||
|
} else {
|
||||||
|
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if privateKey == "" {
|
||||||
|
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gatewayservice.ServiceWallet{
|
||||||
|
Network: cfg.Chain,
|
||||||
|
Address: address,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager.Manager, error) {
|
||||||
|
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
|
||||||
|
if driver == "" {
|
||||||
|
err := merrors.InvalidArgument("key management driver is not configured")
|
||||||
|
logger.Error("key management driver missing")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch keymanager.Driver(driver) {
|
||||||
|
case keymanager.DriverVault:
|
||||||
|
settings := vaultmanager.Config{}
|
||||||
|
if len(cfg.Settings) > 0 {
|
||||||
|
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))
|
||||||
|
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager, err := vaultmanager.New(logger, settings)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to initialise vault key manager", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return manager, nil
|
||||||
|
default:
|
||||||
|
err := merrors.InvalidArgument("unsupported key management driver: " + driver)
|
||||||
|
logger.Error("unsupported key management driver", zap.String("driver", driver))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
12
api/chain/gateway/internal/server/server.go
Normal file
12
api/chain/gateway/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/chain/gateway/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create constructs the chain gateway server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
|
||||||
|
func cloneMoney(m *moneyv1.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMetadata(input map[string]string) map[string]string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := make(map[string]string, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
clone[k] = v
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
385
api/chain/gateway/internal/service/gateway/executor.go
Normal file
385
api/chain/gateway/internal/service/gateway/executor.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransferExecutor handles on-chain submission of transfers.
|
||||||
|
type TransferExecutor interface {
|
||||||
|
SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error)
|
||||||
|
AwaitConfirmation(ctx context.Context, network Network, txHash string) (*types.Receipt, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||||
|
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
||||||
|
return &onChainExecutor{
|
||||||
|
logger: logger.Named("executor"),
|
||||||
|
keyManager: keyManager,
|
||||||
|
clients: map[string]*ethclient.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type onChainExecutor struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
keyManager keymanager.Manager
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[string]*ethclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error) {
|
||||||
|
if o.keyManager == nil {
|
||||||
|
o.logger.Error("key manager not configured")
|
||||||
|
return "", executorInternal("key manager is not configured", nil)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
|
if rpcURL == "" {
|
||||||
|
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
|
||||||
|
return "", executorInvalid("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
if source == nil || transfer == nil {
|
||||||
|
o.logger.Error("transfer context missing")
|
||||||
|
return "", executorInvalid("transfer context missing")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.KeyReference) == "" {
|
||||||
|
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||||
|
return "", executorInvalid("source wallet missing key reference")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.DepositAddress) == "" {
|
||||||
|
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||||
|
return "", executorInvalid("source wallet missing deposit address")
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(destinationAddress) {
|
||||||
|
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||||
|
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
o.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(destinationAddress)),
|
||||||
|
)
|
||||||
|
|
||||||
|
client, err := o.getClient(ctx, rpcURL)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to initialise rpc client",
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("rpc_url", rpcURL),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceAddress := common.HexToAddress(source.DepositAddress)
|
||||||
|
destination := common.HexToAddress(destinationAddress)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to fetch nonce",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to suggest gas price",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to suggest gas price", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx *types.Transaction
|
||||||
|
var txHash string
|
||||||
|
|
||||||
|
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||||
|
|
||||||
|
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||||
|
o.logger.Warn("invalid token contract address",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", transfer.ContractAddress),
|
||||||
|
)
|
||||||
|
return "", executorInvalid("invalid token contract address " + transfer.ContractAddress)
|
||||||
|
}
|
||||||
|
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||||
|
|
||||||
|
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to read token decimals",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", transfer.ContractAddress),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := transfer.NetAmount
|
||||||
|
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||||
|
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return "", executorInvalid("transfer missing net amount")
|
||||||
|
}
|
||||||
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to convert amount to base units",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("amount", amount.Amount),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := erc20ABI.Pack("transfer", destination, amountInt)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to encode transfer call",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to encode transfer call", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
callMsg := ethereum.CallMsg{
|
||||||
|
From: sourceAddress,
|
||||||
|
To: &tokenAddress,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Data: input,
|
||||||
|
}
|
||||||
|
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to estimate gas",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to estimate gas", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||||
|
|
||||||
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to sign transaction",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
|
o.logger.Warn("failed to send transaction",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", executorInternal("failed to send transaction", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash = signedTx.Hash().Hex()
|
||||||
|
o.logger.Info("transaction submitted",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 Network, txHash string) (*types.Receipt, error) {
|
||||||
|
if strings.TrimSpace(txHash) == "" {
|
||||||
|
o.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 == "" {
|
||||||
|
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||||
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := o.getClient(ctx, rpcURL)
|
||||||
|
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:
|
||||||
|
o.logger.Debug("transaction not yet mined",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
case <-ctx.Done():
|
||||||
|
o.logger.Warn("context cancelled while awaiting confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.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)
|
||||||
|
}
|
||||||
|
o.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
erc20ABI abi.ABI
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
|
||||||
|
if err != nil {
|
||||||
|
panic("executor: 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"
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
||||||
|
callData, err := erc20ABI.Pack("decimals")
|
||||||
|
if err != nil {
|
||||||
|
return 0, executorInternal("failed to encode decimals call", err)
|
||||||
|
}
|
||||||
|
msg := ethereum.CallMsg{
|
||||||
|
To: &token,
|
||||||
|
Data: callData,
|
||||||
|
}
|
||||||
|
output, err := client.CallContract(ctx, msg, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, executorInternal("decimals call failed", err)
|
||||||
|
}
|
||||||
|
values, err := erc20ABI.Unpack("decimals", output)
|
||||||
|
if err != nil {
|
||||||
|
return 0, executorInternal("failed to unpack decimals", err)
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
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) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, executorInvalid("invalid amount " + amount + ": " + err.Error())
|
||||||
|
}
|
||||||
|
if value.IsNegative() {
|
||||||
|
return nil, executorInvalid("amount must be positive")
|
||||||
|
}
|
||||||
|
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
||||||
|
scaled := value.Mul(multiplier)
|
||||||
|
if !scaled.Equal(scaled.Truncate(0)) {
|
||||||
|
return nil, executorInvalid("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)
|
||||||
|
}
|
||||||
65
api/chain/gateway/internal/service/gateway/metrics.go
Normal file
65
api/chain/gateway/internal/service/gateway/metrics.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
rpcLatency *prometheus.HistogramVec
|
||||||
|
rpcStatus *prometheus.CounterVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "chain_gateway",
|
||||||
|
Name: "rpc_latency_seconds",
|
||||||
|
Help: "Latency distribution for chain gateway RPC handlers.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"method"})
|
||||||
|
|
||||||
|
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "chain_gateway",
|
||||||
|
Name: "rpc_requests_total",
|
||||||
|
Help: "Total number of RPC invocations grouped by method and status.",
|
||||||
|
}, []string{"method", "status"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeRPC(method string, err error, duration time.Duration) {
|
||||||
|
if rpcLatency != nil {
|
||||||
|
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
|
||||||
|
}
|
||||||
|
if rpcStatus != nil {
|
||||||
|
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(err error) string {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return "ok"
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return "invalid_argument"
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return "not_found"
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return "conflict"
|
||||||
|
case errors.Is(err, merrors.ErrAccessDenied):
|
||||||
|
return "denied"
|
||||||
|
case errors.Is(err, merrors.ErrInternal):
|
||||||
|
return "internal"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
api/chain/gateway/internal/service/gateway/options.go
Normal file
90
api/chain/gateway/internal/service/gateway/options.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option configures the Service.
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// Network describes a supported blockchain network and known token contracts.
|
||||||
|
type Network struct {
|
||||||
|
Name string
|
||||||
|
RPCURL string
|
||||||
|
ChainID uint64
|
||||||
|
NativeToken string
|
||||||
|
TokenConfigs []TokenContract
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||||
|
type TokenContract struct {
|
||||||
|
Symbol string
|
||||||
|
ContractAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceWallet captures the managed service wallet configuration.
|
||||||
|
type ServiceWallet struct {
|
||||||
|
Network string
|
||||||
|
Address string
|
||||||
|
PrivateKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithKeyManager configures the service key manager.
|
||||||
|
func WithKeyManager(manager keymanager.Manager) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.keyManager = manager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
||||||
|
func WithTransferExecutor(executor TransferExecutor) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.executor = executor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithNetworks configures supported blockchain networks.
|
||||||
|
func WithNetworks(networks []Network) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if len(networks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.networks == nil {
|
||||||
|
s.networks = make(map[string]Network, len(networks))
|
||||||
|
}
|
||||||
|
for _, network := range networks {
|
||||||
|
if network.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clone := network
|
||||||
|
if clone.TokenConfigs == nil {
|
||||||
|
clone.TokenConfigs = []TokenContract{}
|
||||||
|
}
|
||||||
|
for i := range clone.TokenConfigs {
|
||||||
|
clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol))
|
||||||
|
clone.TokenConfigs[i].ContractAddress = strings.ToLower(strings.TrimSpace(clone.TokenConfigs[i].ContractAddress))
|
||||||
|
}
|
||||||
|
clone.Name = strings.ToLower(strings.TrimSpace(clone.Name))
|
||||||
|
s.networks[clone.Name] = clone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithServiceWallet configures the service wallet binding.
|
||||||
|
func WithServiceWallet(wallet ServiceWallet) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.serviceWallet = wallet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithClock overrides the service clock.
|
||||||
|
func WithClock(clk clockpkg.Clock) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if clk != nil {
|
||||||
|
s.clock = clk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
api/chain/gateway/internal/service/gateway/service.go
Normal file
214
api/chain/gateway/internal/service/gateway/service.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceError string
|
||||||
|
|
||||||
|
func (e serviceError) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service implements the ChainGatewayService RPC contract.
|
||||||
|
type Service struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
storage storage.Repository
|
||||||
|
producer msg.Producer
|
||||||
|
clock clockpkg.Clock
|
||||||
|
|
||||||
|
networks map[string]Network
|
||||||
|
serviceWallet ServiceWallet
|
||||||
|
keyManager keymanager.Manager
|
||||||
|
executor TransferExecutor
|
||||||
|
|
||||||
|
gatewayv1.UnimplementedChainGatewayServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs the chain gateway service skeleton.
|
||||||
|
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||||
|
svc := &Service{
|
||||||
|
logger: logger.Named("chain_gateway"),
|
||||||
|
storage: repo,
|
||||||
|
producer: producer,
|
||||||
|
clock: clockpkg.System{},
|
||||||
|
networks: map[string]Network{},
|
||||||
|
}
|
||||||
|
|
||||||
|
initMetrics()
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.clock == nil {
|
||||||
|
svc.clock = clockpkg.System{}
|
||||||
|
}
|
||||||
|
if svc.networks == nil {
|
||||||
|
svc.networks = map[string]Network{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register wires the service onto the provided gRPC router.
|
||||||
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
|
gatewayv1.RegisterChainGatewayServiceServer(reg, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "CreateManagedWallet", s.createManagedWalletHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "GetManagedWallet", s.getManagedWalletHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "ListManagedWallets", s.listManagedWalletsHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "GetWalletBalance", s.getWalletBalanceHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "SubmitTransfer", s.submitTransferHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "GetTransfer", s.getTransferHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "ListTransfers", s.listTransfersHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "EstimateTransferFee", s.estimateTransferFeeHandler, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||||
|
if s.storage == nil {
|
||||||
|
return errStorageUnavailable
|
||||||
|
}
|
||||||
|
return s.storage.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||||
|
start := svc.clock.Now()
|
||||||
|
resp, err := gsresponse.Unary(svc.logger, mservice.ChainGateway, handler)(ctx, req)
|
||||||
|
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveContractAddress(tokens []TokenContract, symbol string) string {
|
||||||
|
upper := strings.ToUpper(symbol)
|
||||||
|
for _, token := range tokens {
|
||||||
|
if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" {
|
||||||
|
return strings.ToLower(token.ContractAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateWalletRef() string {
|
||||||
|
return primitive.NewObjectID().Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateTransferRef() string {
|
||||||
|
return primitive.NewObjectID().Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainKeyFromEnum(chain gatewayv1.ChainNetwork) (string, gatewayv1.ChainNetwork) {
|
||||||
|
if name, ok := gatewayv1.ChainNetwork_name[int32(chain)]; ok {
|
||||||
|
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
|
||||||
|
return key, chain
|
||||||
|
}
|
||||||
|
return "", gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainEnumFromName(name string) gatewayv1.ChainNetwork {
|
||||||
|
if name == "" {
|
||||||
|
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
|
||||||
|
key := "CHAIN_NETWORK_" + upper
|
||||||
|
if val, ok := gatewayv1.ChainNetwork_value[key]; ok {
|
||||||
|
return gatewayv1.ChainNetwork(val)
|
||||||
|
}
|
||||||
|
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedWalletStatusToProto(status model.ManagedWalletStatus) gatewayv1.ManagedWalletStatus {
|
||||||
|
switch status {
|
||||||
|
case model.ManagedWalletStatusActive:
|
||||||
|
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
|
||||||
|
case model.ManagedWalletStatusSuspended:
|
||||||
|
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
|
||||||
|
case model.ManagedWalletStatusClosed:
|
||||||
|
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
|
||||||
|
default:
|
||||||
|
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferStatusToModel(status gatewayv1.TransferStatus) model.TransferStatus {
|
||||||
|
switch status {
|
||||||
|
case gatewayv1.TransferStatus_TRANSFER_PENDING:
|
||||||
|
return model.TransferStatusPending
|
||||||
|
case gatewayv1.TransferStatus_TRANSFER_SIGNING:
|
||||||
|
return model.TransferStatusSigning
|
||||||
|
case gatewayv1.TransferStatus_TRANSFER_SUBMITTED:
|
||||||
|
return model.TransferStatusSubmitted
|
||||||
|
case gatewayv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||||
|
return model.TransferStatusConfirmed
|
||||||
|
case gatewayv1.TransferStatus_TRANSFER_FAILED:
|
||||||
|
return model.TransferStatusFailed
|
||||||
|
case gatewayv1.TransferStatus_TRANSFER_CANCELLED:
|
||||||
|
return model.TransferStatusCancelled
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferStatusToProto(status model.TransferStatus) gatewayv1.TransferStatus {
|
||||||
|
switch status {
|
||||||
|
case model.TransferStatusPending:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_PENDING
|
||||||
|
case model.TransferStatusSigning:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_SIGNING
|
||||||
|
case model.TransferStatusSubmitted:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_SUBMITTED
|
||||||
|
case model.TransferStatusConfirmed:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_CONFIRMED
|
||||||
|
case model.TransferStatusFailed:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_FAILED
|
||||||
|
case model.TransferStatusCancelled:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_CANCELLED
|
||||||
|
default:
|
||||||
|
return gatewayv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
556
api/chain/gateway/internal/service/gateway/service_test.go
Normal file
556
api/chain/gateway/internal/service/gateway/service_test.go
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
igatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
walletDefaultLimit int64 = 50
|
||||||
|
walletMaxLimit int64 = 200
|
||||||
|
transferDefaultLimit int64 = 50
|
||||||
|
transferMaxLimit int64 = 200
|
||||||
|
depositDefaultLimit int64 = 100
|
||||||
|
depositMaxLimit int64 = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
||||||
|
svc, repo := newTestService(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
req := &igatewayv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-1",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-1",
|
||||||
|
Asset: &igatewayv1.Asset{
|
||||||
|
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "USDC",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.CreateManagedWallet(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.GetWallet())
|
||||||
|
firstRef := resp.GetWallet().GetWalletRef()
|
||||||
|
require.NotEmpty(t, firstRef)
|
||||||
|
|
||||||
|
resp2, err := svc.CreateManagedWallet(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, firstRef, resp2.GetWallet().GetWalletRef())
|
||||||
|
|
||||||
|
// ensure stored only once
|
||||||
|
require.Equal(t, 1, repo.wallets.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
||||||
|
svc, repo := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// create source wallet
|
||||||
|
srcResp, err := svc.CreateManagedWallet(ctx, &igatewayv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-src",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-1",
|
||||||
|
Asset: &igatewayv1.Asset{
|
||||||
|
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "USDC",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
srcRef := srcResp.GetWallet().GetWalletRef()
|
||||||
|
|
||||||
|
// destination wallet
|
||||||
|
dstResp, err := svc.CreateManagedWallet(ctx, &igatewayv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-dst",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-2",
|
||||||
|
Asset: &igatewayv1.Asset{
|
||||||
|
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "USDC",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
dstRef := dstResp.GetWallet().GetWalletRef()
|
||||||
|
|
||||||
|
transferResp, err := svc.SubmitTransfer(ctx, &igatewayv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: "transfer-1",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
SourceWalletRef: srcRef,
|
||||||
|
Destination: &igatewayv1.TransferDestination{
|
||||||
|
Destination: &igatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef},
|
||||||
|
},
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDC", Amount: "100"},
|
||||||
|
Fees: []*igatewayv1.ServiceFeeBreakdown{
|
||||||
|
{
|
||||||
|
FeeCode: "service",
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDC", Amount: "5"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, transferResp.GetTransfer())
|
||||||
|
require.Equal(t, "95", transferResp.GetTransfer().GetNetAmount().GetAmount())
|
||||||
|
|
||||||
|
stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef())
|
||||||
|
require.NotNil(t, stored)
|
||||||
|
require.Equal(t, model.TransferStatusPending, stored.Status)
|
||||||
|
|
||||||
|
// GetTransfer
|
||||||
|
getResp, err := svc.GetTransfer(ctx, &igatewayv1.GetTransferRequest{TransferRef: stored.TransferRef})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, stored.TransferRef, getResp.GetTransfer().GetTransferRef())
|
||||||
|
|
||||||
|
// ListTransfers
|
||||||
|
listResp, err := svc.ListTransfers(ctx, &igatewayv1.ListTransfersRequest{
|
||||||
|
SourceWalletRef: srcRef,
|
||||||
|
Page: &paginationv1.CursorPageRequest{Limit: 10},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listResp.GetTransfers(), 1)
|
||||||
|
require.Equal(t, stored.TransferRef, listResp.GetTransfers()[0].GetTransferRef())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWalletBalance_NotFound(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := svc.GetWalletBalance(ctx, &igatewayv1.GetWalletBalanceRequest{WalletRef: "missing"})
|
||||||
|
require.Error(t, err)
|
||||||
|
st, _ := status.FromError(err)
|
||||||
|
require.Equal(t, codes.NotFound, st.Code())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- in-memory storage implementation ----
|
||||||
|
|
||||||
|
type inMemoryRepository struct {
|
||||||
|
wallets *inMemoryWallets
|
||||||
|
transfers *inMemoryTransfers
|
||||||
|
deposits *inMemoryDeposits
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInMemoryRepository() *inMemoryRepository {
|
||||||
|
return &inMemoryRepository{
|
||||||
|
wallets: newInMemoryWallets(),
|
||||||
|
transfers: newInMemoryTransfers(),
|
||||||
|
deposits: newInMemoryDeposits(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *inMemoryRepository) Ping(context.Context) error { return nil }
|
||||||
|
func (r *inMemoryRepository) Wallets() storage.WalletsStore { return r.wallets }
|
||||||
|
func (r *inMemoryRepository) Transfers() storage.TransfersStore { return r.transfers }
|
||||||
|
func (r *inMemoryRepository) Deposits() storage.DepositsStore { return r.deposits }
|
||||||
|
|
||||||
|
// Wallets store
|
||||||
|
|
||||||
|
type inMemoryWallets struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
wallets map[string]*model.ManagedWallet
|
||||||
|
byIdemp map[string]string
|
||||||
|
balances map[string]*model.WalletBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInMemoryWallets() *inMemoryWallets {
|
||||||
|
return &inMemoryWallets{
|
||||||
|
wallets: make(map[string]*model.ManagedWallet),
|
||||||
|
byIdemp: make(map[string]string),
|
||||||
|
balances: make(map[string]*model.WalletBalance),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inMemoryWallets) count() int {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return len(w.wallets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inMemoryWallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: nil wallet")
|
||||||
|
}
|
||||||
|
wallet.Normalize()
|
||||||
|
if wallet.IdempotencyKey == "" {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingRef, ok := w.byIdemp[wallet.IdempotencyKey]; ok {
|
||||||
|
existing := w.wallets[existingRef]
|
||||||
|
return existing, merrors.ErrDataConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
if wallet.WalletRef == "" {
|
||||||
|
wallet.WalletRef = primitive.NewObjectID().Hex()
|
||||||
|
}
|
||||||
|
if wallet.GetID() == nil || wallet.GetID().IsZero() {
|
||||||
|
wallet.SetID(primitive.NewObjectID())
|
||||||
|
} else {
|
||||||
|
wallet.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.wallets[wallet.WalletRef] = wallet
|
||||||
|
w.byIdemp[wallet.IdempotencyKey] = wallet.WalletRef
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inMemoryWallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
wallet, ok := w.wallets[strings.TrimSpace(walletRef)]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.NoData("wallet not found")
|
||||||
|
}
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inMemoryWallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
items := make([]*model.ManagedWallet, 0, len(w.wallets))
|
||||||
|
for _, wallet := range w.wallets {
|
||||||
|
if filter.OrganizationRef != "" && !strings.EqualFold(wallet.OrganizationRef, filter.OrganizationRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.OwnerRef != "" && !strings.EqualFold(wallet.OwnerRef, filter.OwnerRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.Network != "" && !strings.EqualFold(wallet.Network, filter.Network) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.TokenSymbol != "" && !strings.EqualFold(wallet.TokenSymbol, filter.TokenSymbol) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, wallet)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].ID.Timestamp().Before(items[j].ID.Timestamp())
|
||||||
|
})
|
||||||
|
|
||||||
|
startIndex := 0
|
||||||
|
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||||
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
|
for idx, item := range items {
|
||||||
|
if item.ID.Timestamp().After(oid.Timestamp()) {
|
||||||
|
startIndex = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int(sanitizeLimit(filter.Limit, walletDefaultLimit, walletMaxLimit))
|
||||||
|
end := startIndex + limit
|
||||||
|
hasMore := false
|
||||||
|
if end < len(items) {
|
||||||
|
hasMore = true
|
||||||
|
items = items[startIndex:end]
|
||||||
|
} else {
|
||||||
|
items = items[startIndex:]
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor := ""
|
||||||
|
if hasMore && len(items) > 0 {
|
||||||
|
nextCursor = items[len(items)-1].ID.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.ManagedWalletList{Items: items, NextCursor: nextCursor}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inMemoryWallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if balance == nil {
|
||||||
|
return merrors.InvalidArgument("walletsStore: nil balance")
|
||||||
|
}
|
||||||
|
balance.Normalize()
|
||||||
|
if balance.WalletRef == "" {
|
||||||
|
return merrors.InvalidArgument("walletsStore: empty walletRef for balance")
|
||||||
|
}
|
||||||
|
if balance.CalculatedAt.IsZero() {
|
||||||
|
balance.CalculatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
existing, ok := w.balances[balance.WalletRef]
|
||||||
|
if !ok {
|
||||||
|
if balance.GetID() == nil || balance.GetID().IsZero() {
|
||||||
|
balance.SetID(primitive.NewObjectID())
|
||||||
|
}
|
||||||
|
w.balances[balance.WalletRef] = balance
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
existing.Available = balance.Available
|
||||||
|
existing.PendingInbound = balance.PendingInbound
|
||||||
|
existing.PendingOutbound = balance.PendingOutbound
|
||||||
|
existing.CalculatedAt = balance.CalculatedAt
|
||||||
|
existing.Update()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inMemoryWallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
balance, ok := w.balances[strings.TrimSpace(walletRef)]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.NoData("wallet balance not found")
|
||||||
|
}
|
||||||
|
return balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfers store
|
||||||
|
|
||||||
|
type inMemoryTransfers struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]*model.Transfer
|
||||||
|
byIdemp map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInMemoryTransfers() *inMemoryTransfers {
|
||||||
|
return &inMemoryTransfers{
|
||||||
|
items: make(map[string]*model.Transfer),
|
||||||
|
byIdemp: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *inMemoryTransfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if transfer == nil {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: nil transfer")
|
||||||
|
}
|
||||||
|
transfer.Normalize()
|
||||||
|
if transfer.IdempotencyKey == "" {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
|
||||||
|
}
|
||||||
|
if ref, ok := t.byIdemp[transfer.IdempotencyKey]; ok {
|
||||||
|
return t.items[ref], merrors.ErrDataConflict
|
||||||
|
}
|
||||||
|
if transfer.TransferRef == "" {
|
||||||
|
transfer.TransferRef = primitive.NewObjectID().Hex()
|
||||||
|
}
|
||||||
|
if transfer.GetID() == nil || transfer.GetID().IsZero() {
|
||||||
|
transfer.SetID(primitive.NewObjectID())
|
||||||
|
} else {
|
||||||
|
transfer.Update()
|
||||||
|
}
|
||||||
|
t.items[transfer.TransferRef] = transfer
|
||||||
|
t.byIdemp[transfer.IdempotencyKey] = transfer.TransferRef
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
transfer, ok := t.items[strings.TrimSpace(transferRef)]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.NoData("transfer not found")
|
||||||
|
}
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
items := make([]*model.Transfer, 0, len(t.items))
|
||||||
|
for _, transfer := range t.items {
|
||||||
|
if filter.SourceWalletRef != "" && !strings.EqualFold(transfer.SourceWalletRef, filter.SourceWalletRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.DestinationWalletRef != "" && !strings.EqualFold(transfer.Destination.ManagedWalletRef, filter.DestinationWalletRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.Status != "" && transfer.Status != filter.Status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].ID.Timestamp().Before(items[j].ID.Timestamp())
|
||||||
|
})
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||||
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
|
for idx, item := range items {
|
||||||
|
if item.ID.Timestamp().After(oid.Timestamp()) {
|
||||||
|
start = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int(sanitizeLimit(filter.Limit, transferDefaultLimit, transferMaxLimit))
|
||||||
|
end := start + limit
|
||||||
|
hasMore := false
|
||||||
|
if end < len(items) {
|
||||||
|
hasMore = true
|
||||||
|
items = items[start:end]
|
||||||
|
} else {
|
||||||
|
items = items[start:]
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor := ""
|
||||||
|
if hasMore && len(items) > 0 {
|
||||||
|
nextCursor = items[len(items)-1].ID.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.TransferList{Items: items, NextCursor: nextCursor}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *inMemoryTransfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
transfer, ok := t.items[strings.TrimSpace(transferRef)]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.NoData("transfer not found")
|
||||||
|
}
|
||||||
|
transfer.Status = status
|
||||||
|
if status == model.TransferStatusFailed {
|
||||||
|
transfer.FailureReason = strings.TrimSpace(failureReason)
|
||||||
|
} else {
|
||||||
|
transfer.FailureReason = ""
|
||||||
|
}
|
||||||
|
transfer.TxHash = strings.TrimSpace(txHash)
|
||||||
|
transfer.LastStatusAt = time.Now().UTC()
|
||||||
|
transfer.Update()
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper for tests
|
||||||
|
func (t *inMemoryTransfers) get(ref string) *model.Transfer {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.items[ref]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposits store (minimal for tests)
|
||||||
|
|
||||||
|
type inMemoryDeposits struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]*model.Deposit
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInMemoryDeposits() *inMemoryDeposits {
|
||||||
|
return &inMemoryDeposits{items: make(map[string]*model.Deposit)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *inMemoryDeposits) Record(ctx context.Context, deposit *model.Deposit) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if deposit == nil {
|
||||||
|
return merrors.InvalidArgument("depositsStore: nil deposit")
|
||||||
|
}
|
||||||
|
deposit.Normalize()
|
||||||
|
if deposit.DepositRef == "" {
|
||||||
|
return merrors.InvalidArgument("depositsStore: empty depositRef")
|
||||||
|
}
|
||||||
|
if existing, ok := d.items[deposit.DepositRef]; ok {
|
||||||
|
existing.Status = deposit.Status
|
||||||
|
existing.LastStatusAt = time.Now().UTC()
|
||||||
|
existing.Update()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if deposit.GetID() == nil || deposit.GetID().IsZero() {
|
||||||
|
deposit.SetID(primitive.NewObjectID())
|
||||||
|
}
|
||||||
|
if deposit.ObservedAt.IsZero() {
|
||||||
|
deposit.ObservedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if deposit.RecordedAt.IsZero() {
|
||||||
|
deposit.RecordedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
deposit.LastStatusAt = time.Now().UTC()
|
||||||
|
d.items[deposit.DepositRef] = deposit
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *inMemoryDeposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
results := make([]*model.Deposit, 0)
|
||||||
|
for _, deposit := range d.items {
|
||||||
|
if deposit.Status != model.DepositStatusPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if network != "" && !strings.EqualFold(deposit.Network, network) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, deposit)
|
||||||
|
}
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].ObservedAt.Before(results[j].ObservedAt)
|
||||||
|
})
|
||||||
|
limitVal := int(sanitizeLimit(limit, depositDefaultLimit, depositMaxLimit))
|
||||||
|
if len(results) > limitVal {
|
||||||
|
results = results[:limitVal]
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shared helpers
|
||||||
|
|
||||||
|
func sanitizeLimit(requested int32, def, max int64) int64 {
|
||||||
|
if requested <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if requested > int32(max) {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return int64(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
||||||
|
repo := newInMemoryRepository()
|
||||||
|
logger := zap.NewNop()
|
||||||
|
svc := NewService(logger, repo, nil,
|
||||||
|
WithKeyManager(&fakeKeyManager{}),
|
||||||
|
WithNetworks([]Network{{
|
||||||
|
Name: "ethereum_mainnet",
|
||||||
|
TokenConfigs: []TokenContract{
|
||||||
|
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||||
|
},
|
||||||
|
}}),
|
||||||
|
WithServiceWallet(ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||||
|
)
|
||||||
|
return svc, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeKeyManager struct{}
|
||||||
|
|
||||||
|
func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
|
||||||
|
return &keymanager.ManagedWalletKey{
|
||||||
|
KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef),
|
||||||
|
Address: "0x" + strings.Repeat("a", 40),
|
||||||
|
PublicKey: strings.Repeat("b", 128),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeyManager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||||
|
return tx, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network Network) {
|
||||||
|
if s.executor == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(ref, walletRef string, net Network) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}(transferRef, sourceWalletRef, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network Network) error {
|
||||||
|
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
return 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))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) {
|
||||||
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
|
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
|
}
|
||||||
|
return wallet.DepositAddress, nil
|
||||||
|
}
|
||||||
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
|
return strings.ToLower(addr), nil
|
||||||
|
}
|
||||||
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
|
}
|
||||||
309
api/chain/gateway/internal/service/gateway/transfer_handlers.go
Normal file
309
api/chain/gateway/internal/service/gateway/transfer_handlers.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) submitTransferHandler(ctx context.Context, req *gatewayv1.SubmitTransferRequest) gsresponse.Responder[gatewayv1.SubmitTransferResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
|
}
|
||||||
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
if organizationRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
|
}
|
||||||
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
if sourceWalletRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
amount := req.GetAmount()
|
||||||
|
if amount == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||||
|
}
|
||||||
|
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||||
|
if amountCurrency == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||||
|
}
|
||||||
|
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||||
|
if amountValue == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||||
|
}
|
||||||
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
|
networkCfg, ok := s.networks[networkKey]
|
||||||
|
if !ok {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
}
|
||||||
|
|
||||||
|
destination, err := s.resolveDestination(ctx, req.GetDestination(), sourceWallet)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
amountDec, err := decimal.NewFromString(amountValue)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||||
|
}
|
||||||
|
netDec := amountDec.Sub(feeSum)
|
||||||
|
if netDec.IsNegative() {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||||
|
}
|
||||||
|
|
||||||
|
netAmount := cloneMoney(amount)
|
||||||
|
netAmount.Amount = netDec.String()
|
||||||
|
|
||||||
|
transfer := &model.Transfer{
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
TransferRef: generateTransferRef(),
|
||||||
|
OrganizationRef: organizationRef,
|
||||||
|
SourceWalletRef: sourceWalletRef,
|
||||||
|
Destination: destination,
|
||||||
|
Network: sourceWallet.Network,
|
||||||
|
TokenSymbol: sourceWallet.TokenSymbol,
|
||||||
|
ContractAddress: sourceWallet.ContractAddress,
|
||||||
|
RequestedAmount: cloneMoney(amount),
|
||||||
|
NetAmount: netAmount,
|
||||||
|
Fees: fees,
|
||||||
|
Status: model.TransferStatusPending,
|
||||||
|
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||||
|
LastStatusAt: s.clock.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := s.storage.Transfers().Create(ctx, transfer)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
s.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||||
|
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)})
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.executor != nil {
|
||||||
|
s.launchTransferExecution(saved.TransferRef, sourceWalletRef, networkCfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getTransferHandler(ctx context.Context, req *gatewayv1.GetTransferRequest) gsresponse.Responder[gatewayv1.GetTransferResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||||
|
if transferRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
||||||
|
}
|
||||||
|
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return gsresponse.NotFound[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(&gatewayv1.GetTransferResponse{Transfer: s.toProtoTransfer(transfer)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) listTransfersHandler(ctx context.Context, req *gatewayv1.ListTransfersRequest) gsresponse.Responder[gatewayv1.ListTransfersResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
filter := model.TransferFilter{}
|
||||||
|
if req != nil {
|
||||||
|
filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef())
|
||||||
|
if status := transferStatusToModel(req.GetStatus()); status != "" {
|
||||||
|
filter.Status = status
|
||||||
|
}
|
||||||
|
if page := req.GetPage(); page != nil {
|
||||||
|
filter.Cursor = strings.TrimSpace(page.GetCursor())
|
||||||
|
filter.Limit = page.GetLimit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.storage.Transfers().List(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protoTransfers := make([]*gatewayv1.Transfer, 0, len(result.Items))
|
||||||
|
for _, transfer := range result.Items {
|
||||||
|
protoTransfers = append(protoTransfers, s.toProtoTransfer(transfer))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &gatewayv1.ListTransfersResponse{
|
||||||
|
Transfers: protoTransfers,
|
||||||
|
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) estimateTransferFeeHandler(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) gsresponse.Responder[gatewayv1.EstimateTransferFeeResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil || req.GetAmount() == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||||
|
}
|
||||||
|
currency := req.GetAmount().GetCurrency()
|
||||||
|
fee := &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: "0",
|
||||||
|
}
|
||||||
|
resp := &gatewayv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: fee,
|
||||||
|
EstimationContext: "not_implemented",
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toProtoTransfer(transfer *model.Transfer) *gatewayv1.Transfer {
|
||||||
|
if transfer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
destination := &gatewayv1.TransferDestination{}
|
||||||
|
if transfer.Destination.ManagedWalletRef != "" {
|
||||||
|
destination.Destination = &gatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef}
|
||||||
|
} else if transfer.Destination.ExternalAddress != "" {
|
||||||
|
destination.Destination = &gatewayv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress}
|
||||||
|
}
|
||||||
|
destination.Memo = transfer.Destination.Memo
|
||||||
|
|
||||||
|
protoFees := make([]*gatewayv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
|
||||||
|
for _, fee := range transfer.Fees {
|
||||||
|
protoFees = append(protoFees, &gatewayv1.ServiceFeeBreakdown{
|
||||||
|
FeeCode: fee.FeeCode,
|
||||||
|
Amount: cloneMoney(fee.Amount),
|
||||||
|
Description: fee.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
asset := &gatewayv1.Asset{
|
||||||
|
Chain: chainEnumFromName(transfer.Network),
|
||||||
|
TokenSymbol: transfer.TokenSymbol,
|
||||||
|
ContractAddress: transfer.ContractAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &gatewayv1.Transfer{
|
||||||
|
TransferRef: transfer.TransferRef,
|
||||||
|
IdempotencyKey: transfer.IdempotencyKey,
|
||||||
|
OrganizationRef: transfer.OrganizationRef,
|
||||||
|
SourceWalletRef: transfer.SourceWalletRef,
|
||||||
|
Destination: destination,
|
||||||
|
Asset: asset,
|
||||||
|
RequestedAmount: cloneMoney(transfer.RequestedAmount),
|
||||||
|
NetAmount: cloneMoney(transfer.NetAmount),
|
||||||
|
Fees: protoFees,
|
||||||
|
Status: transferStatusToProto(transfer.Status),
|
||||||
|
TransactionHash: transfer.TxHash,
|
||||||
|
FailureReason: transfer.FailureReason,
|
||||||
|
CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()),
|
||||||
|
UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveDestination(ctx context.Context, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) {
|
||||||
|
if dest == nil {
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
|
}
|
||||||
|
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||||
|
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||||
|
if managedRef != "" && external != "" {
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||||
|
}
|
||||||
|
if managedRef != "" {
|
||||||
|
wallet, err := s.storage.Wallets().Get(ctx, managedRef)
|
||||||
|
if err != nil {
|
||||||
|
return model.TransferDestination{}, err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||||
|
}
|
||||||
|
return model.TransferDestination{
|
||||||
|
ManagedWalletRef: wallet.WalletRef,
|
||||||
|
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if external == "" {
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
|
}
|
||||||
|
return model.TransferDestination{
|
||||||
|
ExternalAddress: strings.ToLower(external),
|
||||||
|
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertFees(fees []*gatewayv1.ServiceFeeBreakdown, currency string) ([]model.ServiceFee, decimal.Decimal, error) {
|
||||||
|
result := make([]model.ServiceFee, 0, len(fees))
|
||||||
|
sum := decimal.NewFromInt(0)
|
||||||
|
for _, fee := range fees {
|
||||||
|
if fee == nil || fee.GetAmount() == nil {
|
||||||
|
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
|
||||||
|
}
|
||||||
|
amtCurrency := strings.ToUpper(strings.TrimSpace(fee.GetAmount().GetCurrency()))
|
||||||
|
if amtCurrency != strings.ToUpper(currency) {
|
||||||
|
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee currency mismatch")
|
||||||
|
}
|
||||||
|
amtValue := strings.TrimSpace(fee.GetAmount().GetAmount())
|
||||||
|
if amtValue == "" {
|
||||||
|
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
|
||||||
|
}
|
||||||
|
dec, err := decimal.NewFromString(amtValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decimal.Decimal{}, merrors.InvalidArgument("invalid fee amount")
|
||||||
|
}
|
||||||
|
if dec.IsNegative() {
|
||||||
|
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount must be non-negative")
|
||||||
|
}
|
||||||
|
sum = sum.Add(dec)
|
||||||
|
result = append(result, model.ServiceFee{
|
||||||
|
FeeCode: strings.TrimSpace(fee.GetFeeCode()),
|
||||||
|
Amount: cloneMoney(fee.GetAmount()),
|
||||||
|
Description: strings.TrimSpace(fee.GetDescription()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, sum, nil
|
||||||
|
}
|
||||||
213
api/chain/gateway/internal/service/gateway/wallet_handlers.go
Normal file
213
api/chain/gateway/internal/service/gateway/wallet_handlers.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gatewayv1 "github.com/tech/sendico/chain/gateway/internal/generated/service/gateway/v1"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) createManagedWalletHandler(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) gsresponse.Responder[gatewayv1.CreateManagedWalletResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
|
}
|
||||||
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
if organizationRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
|
}
|
||||||
|
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||||
|
if ownerRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
asset := req.GetAsset()
|
||||||
|
if asset == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
chainKey, _ := chainKeyFromEnum(asset.GetChain())
|
||||||
|
if chainKey == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
|
}
|
||||||
|
networkCfg, ok := s.networks[chainKey]
|
||||||
|
if !ok {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
|
if tokenSymbol == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||||
|
}
|
||||||
|
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||||
|
if contractAddress == "" {
|
||||||
|
contractAddress = resolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||||
|
if contractAddress == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walletRef := generateWalletRef()
|
||||||
|
if s.keyManager == nil {
|
||||||
|
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||||
|
}
|
||||||
|
|
||||||
|
keyInfo, err := s.keyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||||
|
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet := &model.ManagedWallet{
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
WalletRef: walletRef,
|
||||||
|
OrganizationRef: organizationRef,
|
||||||
|
OwnerRef: ownerRef,
|
||||||
|
Network: chainKey,
|
||||||
|
TokenSymbol: tokenSymbol,
|
||||||
|
ContractAddress: contractAddress,
|
||||||
|
DepositAddress: strings.ToLower(keyInfo.Address),
|
||||||
|
KeyReference: keyInfo.KeyID,
|
||||||
|
Status: model.ManagedWalletStatusActive,
|
||||||
|
Metadata: cloneMetadata(req.GetMetadata()),
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.storage.Wallets().Create(ctx, wallet)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
s.logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||||
|
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getManagedWalletHandler(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) gsresponse.Responder[gatewayv1.GetManagedWalletResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
|
if walletRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
|
}
|
||||||
|
wallet, err := s.storage.Wallets().Get(ctx, walletRef)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return gsresponse.NotFound[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(&gatewayv1.GetManagedWalletResponse{Wallet: s.toProtoManagedWallet(wallet)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) listManagedWalletsHandler(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) gsresponse.Responder[gatewayv1.ListManagedWalletsResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
filter := model.ManagedWalletFilter{}
|
||||||
|
if req != nil {
|
||||||
|
filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
filter.OwnerRef = strings.TrimSpace(req.GetOwnerRef())
|
||||||
|
if asset := req.GetAsset(); asset != nil {
|
||||||
|
filter.Network, _ = chainKeyFromEnum(asset.GetChain())
|
||||||
|
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
|
||||||
|
}
|
||||||
|
if page := req.GetPage(); page != nil {
|
||||||
|
filter.Cursor = strings.TrimSpace(page.GetCursor())
|
||||||
|
filter.Limit = page.GetLimit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.storage.Wallets().List(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protoWallets := make([]*gatewayv1.ManagedWallet, 0, len(result.Items))
|
||||||
|
for _, wallet := range result.Items {
|
||||||
|
protoWallets = append(protoWallets, s.toProtoManagedWallet(wallet))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &gatewayv1.ListManagedWalletsResponse{
|
||||||
|
Wallets: protoWallets,
|
||||||
|
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getWalletBalanceHandler(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) gsresponse.Responder[gatewayv1.GetWalletBalanceResponse] {
|
||||||
|
if err := s.ensureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
|
if walletRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
|
}
|
||||||
|
balance, err := s.storage.Wallets().GetBalance(ctx, walletRef)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return gsresponse.NotFound[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(balance)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toProtoManagedWallet(wallet *model.ManagedWallet) *gatewayv1.ManagedWallet {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
asset := &gatewayv1.Asset{
|
||||||
|
Chain: chainEnumFromName(wallet.Network),
|
||||||
|
TokenSymbol: wallet.TokenSymbol,
|
||||||
|
ContractAddress: wallet.ContractAddress,
|
||||||
|
}
|
||||||
|
return &gatewayv1.ManagedWallet{
|
||||||
|
WalletRef: wallet.WalletRef,
|
||||||
|
OrganizationRef: wallet.OrganizationRef,
|
||||||
|
OwnerRef: wallet.OwnerRef,
|
||||||
|
Asset: asset,
|
||||||
|
DepositAddress: wallet.DepositAddress,
|
||||||
|
Status: managedWalletStatusToProto(wallet.Status),
|
||||||
|
Metadata: cloneMetadata(wallet.Metadata),
|
||||||
|
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||||
|
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoWalletBalance(balance *model.WalletBalance) *gatewayv1.WalletBalance {
|
||||||
|
if balance == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &gatewayv1.WalletBalance{
|
||||||
|
Available: cloneMoney(balance.Available),
|
||||||
|
PendingInbound: cloneMoney(balance.PendingInbound),
|
||||||
|
PendingOutbound: cloneMoney(balance.PendingOutbound),
|
||||||
|
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||||
|
}
|
||||||
|
}
|
||||||
17
api/chain/gateway/main.go
Normal file
17
api/chain/gateway/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/chain/gateway/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/chain/gateway/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)
|
||||||
|
}
|
||||||
54
api/chain/gateway/storage/model/deposit.go
Normal file
54
api/chain/gateway/storage/model/deposit.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DepositStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DepositStatusPending DepositStatus = "pending"
|
||||||
|
DepositStatusConfirmed DepositStatus = "confirmed"
|
||||||
|
DepositStatusFailed DepositStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deposit records an inbound transfer observed on-chain.
|
||||||
|
type Deposit struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
|
DepositRef string `bson:"depositRef" json:"depositRef"`
|
||||||
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
|
Network string `bson:"network" json:"network"`
|
||||||
|
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
|
||||||
|
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
|
||||||
|
Amount *moneyv1.Money `bson:"amount" json:"amount"`
|
||||||
|
SourceAddress string `bson:"sourceAddress" json:"sourceAddress"`
|
||||||
|
TxHash string `bson:"txHash" json:"txHash"`
|
||||||
|
BlockID string `bson:"blockId,omitempty" json:"blockId,omitempty"`
|
||||||
|
Status DepositStatus `bson:"status" json:"status"`
|
||||||
|
ObservedAt time.Time `bson:"observedAt" json:"observedAt"`
|
||||||
|
RecordedAt time.Time `bson:"recordedAt" json:"recordedAt"`
|
||||||
|
LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*Deposit) Collection() string {
|
||||||
|
return mservice.ChainDeposits
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize standardizes case-sensitive fields.
|
||||||
|
func (d *Deposit) Normalize() {
|
||||||
|
d.DepositRef = strings.TrimSpace(d.DepositRef)
|
||||||
|
d.WalletRef = strings.TrimSpace(d.WalletRef)
|
||||||
|
d.Network = strings.TrimSpace(strings.ToLower(d.Network))
|
||||||
|
d.TokenSymbol = strings.TrimSpace(strings.ToUpper(d.TokenSymbol))
|
||||||
|
d.ContractAddress = strings.TrimSpace(strings.ToLower(d.ContractAddress))
|
||||||
|
d.SourceAddress = strings.TrimSpace(strings.ToLower(d.SourceAddress))
|
||||||
|
d.TxHash = strings.TrimSpace(strings.ToLower(d.TxHash))
|
||||||
|
d.BlockID = strings.TrimSpace(d.BlockID)
|
||||||
|
}
|
||||||
91
api/chain/gateway/storage/model/transfer.go
Normal file
91
api/chain/gateway/storage/model/transfer.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TransferStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransferStatusPending TransferStatus = "pending"
|
||||||
|
TransferStatusSigning TransferStatus = "signing"
|
||||||
|
TransferStatusSubmitted TransferStatus = "submitted"
|
||||||
|
TransferStatusConfirmed TransferStatus = "confirmed"
|
||||||
|
TransferStatusFailed TransferStatus = "failed"
|
||||||
|
TransferStatusCancelled TransferStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceFee represents a fee component applied to a transfer.
|
||||||
|
type ServiceFee struct {
|
||||||
|
FeeCode string `bson:"feeCode" json:"feeCode"`
|
||||||
|
Amount *moneyv1.Money `bson:"amount" json:"amount"`
|
||||||
|
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferDestination struct {
|
||||||
|
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
|
||||||
|
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
|
||||||
|
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer models an on-chain transfer orchestrated by the gateway.
|
||||||
|
type Transfer struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
|
TransferRef string `bson:"transferRef" json:"transferRef"`
|
||||||
|
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||||
|
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
|
||||||
|
SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"`
|
||||||
|
Destination TransferDestination `bson:"destination" json:"destination"`
|
||||||
|
Network string `bson:"network" json:"network"`
|
||||||
|
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
|
||||||
|
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
|
||||||
|
RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"`
|
||||||
|
NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"`
|
||||||
|
Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"`
|
||||||
|
Status TransferStatus `bson:"status" json:"status"`
|
||||||
|
TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"`
|
||||||
|
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
|
||||||
|
ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"`
|
||||||
|
LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*Transfer) Collection() string {
|
||||||
|
return mservice.ChainTransfers
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferFilter describes the parameters for listing transfers.
|
||||||
|
type TransferFilter struct {
|
||||||
|
SourceWalletRef string
|
||||||
|
DestinationWalletRef string
|
||||||
|
Status TransferStatus
|
||||||
|
Cursor string
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferList contains paginated transfer results.
|
||||||
|
type TransferList struct {
|
||||||
|
Items []*Transfer
|
||||||
|
NextCursor string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize trims strings for consistent indexes.
|
||||||
|
func (t *Transfer) Normalize() {
|
||||||
|
t.TransferRef = strings.TrimSpace(t.TransferRef)
|
||||||
|
t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey)
|
||||||
|
t.OrganizationRef = strings.TrimSpace(t.OrganizationRef)
|
||||||
|
t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef)
|
||||||
|
t.Network = strings.TrimSpace(strings.ToLower(t.Network))
|
||||||
|
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
|
||||||
|
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
|
||||||
|
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
|
||||||
|
t.Destination.ExternalAddress = strings.TrimSpace(strings.ToLower(t.Destination.ExternalAddress))
|
||||||
|
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
|
||||||
|
t.ClientReference = strings.TrimSpace(t.ClientReference)
|
||||||
|
}
|
||||||
90
api/chain/gateway/storage/model/wallet.go
Normal file
90
api/chain/gateway/storage/model/wallet.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ManagedWalletStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ManagedWalletStatusActive ManagedWalletStatus = "active"
|
||||||
|
ManagedWalletStatusSuspended ManagedWalletStatus = "suspended"
|
||||||
|
ManagedWalletStatusClosed ManagedWalletStatus = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
||||||
|
type ManagedWallet struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
|
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||||
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
|
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
|
||||||
|
OwnerRef string `bson:"ownerRef" json:"ownerRef"`
|
||||||
|
Network string `bson:"network" json:"network"`
|
||||||
|
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
|
||||||
|
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
|
||||||
|
DepositAddress string `bson:"depositAddress" json:"depositAddress"`
|
||||||
|
KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"`
|
||||||
|
Status ManagedWalletStatus `bson:"status" json:"status"`
|
||||||
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*ManagedWallet) Collection() string {
|
||||||
|
return mservice.ChainWallets
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletBalance captures computed wallet balances.
|
||||||
|
type WalletBalance struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
|
Available *moneyv1.Money `bson:"available" json:"available"`
|
||||||
|
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
||||||
|
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
||||||
|
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*WalletBalance) Collection() string {
|
||||||
|
return mservice.ChainWalletBalances
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedWalletFilter describes list filters.
|
||||||
|
type ManagedWalletFilter struct {
|
||||||
|
OrganizationRef string
|
||||||
|
OwnerRef string
|
||||||
|
Network string
|
||||||
|
TokenSymbol string
|
||||||
|
Cursor string
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedWalletList contains paginated wallet results.
|
||||||
|
type ManagedWalletList struct {
|
||||||
|
Items []*ManagedWallet
|
||||||
|
NextCursor string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize trims string fields for consistent indexing.
|
||||||
|
func (m *ManagedWallet) Normalize() {
|
||||||
|
m.IdempotencyKey = strings.TrimSpace(m.IdempotencyKey)
|
||||||
|
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
||||||
|
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
||||||
|
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
||||||
|
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||||
|
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||||
|
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||||
|
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
|
||||||
|
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize trims wallet balance identifiers.
|
||||||
|
func (b *WalletBalance) Normalize() {
|
||||||
|
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
||||||
|
}
|
||||||
98
api/chain/gateway/storage/mongo/repository.go
Normal file
98
api/chain/gateway/storage/mongo/repository.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/mongo/store"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store implements storage.Repository backed by MongoDB.
|
||||||
|
type Store struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
conn *db.MongoConnection
|
||||||
|
db *mongo.Database
|
||||||
|
|
||||||
|
wallets storage.WalletsStore
|
||||||
|
transfers storage.TransfersStore
|
||||||
|
deposits storage.DepositsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Mongo-backed repository.
|
||||||
|
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||||
|
if conn == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo connection is nil")
|
||||||
|
}
|
||||||
|
client := conn.Client()
|
||||||
|
if client == nil {
|
||||||
|
return nil, merrors.Internal("mongo client is not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &Store{
|
||||||
|
logger: logger.Named("storage").Named("mongo"),
|
||||||
|
conn: conn,
|
||||||
|
db: conn.Database(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := result.Ping(ctx); err != nil {
|
||||||
|
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
walletsStore, err := store.NewWallets(result.logger, result.db)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("failed to initialise wallets store", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transfersStore, err := store.NewTransfers(result.logger, result.db)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("failed to initialise transfers store", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
depositsStore, err := store.NewDeposits(result.logger, result.db)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("failed to initialise deposits store", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.wallets = walletsStore
|
||||||
|
result.transfers = transfersStore
|
||||||
|
result.deposits = depositsStore
|
||||||
|
|
||||||
|
result.logger.Info("Chain gateway MongoDB storage initialised")
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping verifies the MongoDB connection.
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
if s.conn == nil {
|
||||||
|
return merrors.InvalidArgument("mongo connection is nil")
|
||||||
|
}
|
||||||
|
return s.conn.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wallets returns the wallets store.
|
||||||
|
func (s *Store) Wallets() storage.WalletsStore {
|
||||||
|
return s.wallets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfers returns the transfers store.
|
||||||
|
func (s *Store) Transfers() storage.TransfersStore {
|
||||||
|
return s.transfers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposits returns the deposits store.
|
||||||
|
func (s *Store) Deposits() storage.DepositsStore {
|
||||||
|
return s.deposits
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Repository = (*Store)(nil)
|
||||||
161
api/chain/gateway/storage/mongo/store/deposits.go
Normal file
161
api/chain/gateway/storage/mongo/store/deposits.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDepositPageSize int64 = 100
|
||||||
|
maxDepositPageSize int64 = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
type Deposits struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeposits constructs a Mongo-backed deposits store.
|
||||||
|
func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo database is nil")
|
||||||
|
}
|
||||||
|
repo := repository.CreateMongoRepository(db, mservice.ChainDeposits)
|
||||||
|
indexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "depositRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "txHash", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, def := range indexes {
|
||||||
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
|
logger.Error("failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
childLogger := logger.Named("deposits")
|
||||||
|
childLogger.Debug("deposits store initialised")
|
||||||
|
|
||||||
|
return &Deposits{logger: childLogger, repo: repo}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deposits) Record(ctx context.Context, deposit *model.Deposit) error {
|
||||||
|
if deposit == nil {
|
||||||
|
return merrors.InvalidArgument("depositsStore: nil deposit")
|
||||||
|
}
|
||||||
|
deposit.Normalize()
|
||||||
|
if strings.TrimSpace(deposit.DepositRef) == "" {
|
||||||
|
return merrors.InvalidArgument("depositsStore: empty depositRef")
|
||||||
|
}
|
||||||
|
if deposit.Status == "" {
|
||||||
|
deposit.Status = model.DepositStatusPending
|
||||||
|
}
|
||||||
|
if deposit.ObservedAt.IsZero() {
|
||||||
|
deposit.ObservedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if deposit.RecordedAt.IsZero() {
|
||||||
|
deposit.RecordedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if deposit.LastStatusAt.IsZero() {
|
||||||
|
deposit.LastStatusAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := &model.Deposit{}
|
||||||
|
err := d.repo.FindOneByFilter(ctx, repository.Filter("depositRef", deposit.DepositRef), existing)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
existing.Status = deposit.Status
|
||||||
|
existing.ObservedAt = deposit.ObservedAt
|
||||||
|
existing.RecordedAt = deposit.RecordedAt
|
||||||
|
existing.LastStatusAt = time.Now().UTC()
|
||||||
|
if deposit.Amount != nil {
|
||||||
|
existing.Amount = deposit.Amount
|
||||||
|
}
|
||||||
|
if deposit.BlockID != "" {
|
||||||
|
existing.BlockID = deposit.BlockID
|
||||||
|
}
|
||||||
|
if deposit.TxHash != "" {
|
||||||
|
existing.TxHash = deposit.TxHash
|
||||||
|
}
|
||||||
|
if deposit.Network != "" {
|
||||||
|
existing.Network = deposit.Network
|
||||||
|
}
|
||||||
|
if deposit.TokenSymbol != "" {
|
||||||
|
existing.TokenSymbol = deposit.TokenSymbol
|
||||||
|
}
|
||||||
|
if deposit.ContractAddress != "" {
|
||||||
|
existing.ContractAddress = deposit.ContractAddress
|
||||||
|
}
|
||||||
|
if deposit.SourceAddress != "" {
|
||||||
|
existing.SourceAddress = deposit.SourceAddress
|
||||||
|
}
|
||||||
|
if err := d.repo.Update(ctx, existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
if err := d.repo.Insert(ctx, deposit, repository.Filter("depositRef", deposit.DepositRef)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) {
|
||||||
|
query := repository.Query().Filter(repository.Field("status"), model.DepositStatusPending)
|
||||||
|
if net := strings.TrimSpace(network); net != "" {
|
||||||
|
query = query.Filter(repository.Field("network"), strings.ToLower(net))
|
||||||
|
}
|
||||||
|
pageSize := sanitizeDepositLimit(limit)
|
||||||
|
query = query.Sort(repository.Field("observedAt"), true).Limit(&pageSize)
|
||||||
|
|
||||||
|
deposits := make([]*model.Deposit, 0, pageSize)
|
||||||
|
decoder := func(cur *mongo.Cursor) error {
|
||||||
|
item := &model.Deposit{}
|
||||||
|
if err := cur.Decode(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deposits = append(deposits, item)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return deposits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeDepositLimit(requested int32) int64 {
|
||||||
|
if requested <= 0 {
|
||||||
|
return defaultDepositPageSize
|
||||||
|
}
|
||||||
|
if requested > int32(maxDepositPageSize) {
|
||||||
|
return maxDepositPageSize
|
||||||
|
}
|
||||||
|
return int64(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.DepositsStore = (*Deposits)(nil)
|
||||||
200
api/chain/gateway/storage/mongo/store/transfers.go
Normal file
200
api/chain/gateway/storage/mongo/store/transfers.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTransferPageSize int64 = 50
|
||||||
|
maxTransferPageSize int64 = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transfers struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransfers constructs a Mongo-backed transfers store.
|
||||||
|
func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo database is nil")
|
||||||
|
}
|
||||||
|
repo := repository.CreateMongoRepository(db, mservice.ChainTransfers)
|
||||||
|
indexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "sourceWalletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "destination.managedWalletRef", Sort: ri.Asc}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, def := range indexes {
|
||||||
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
|
logger.Error("failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
childLogger := logger.Named("transfers")
|
||||||
|
childLogger.Debug("transfers store initialised")
|
||||||
|
|
||||||
|
return &Transfers{
|
||||||
|
logger: childLogger,
|
||||||
|
repo: repo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) {
|
||||||
|
if transfer == nil {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: nil transfer")
|
||||||
|
}
|
||||||
|
transfer.Normalize()
|
||||||
|
if strings.TrimSpace(transfer.TransferRef) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(transfer.IdempotencyKey) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
|
||||||
|
}
|
||||||
|
if transfer.Status == "" {
|
||||||
|
transfer.Status = model.TransferStatusPending
|
||||||
|
}
|
||||||
|
if transfer.LastStatusAt.IsZero() {
|
||||||
|
transfer.LastStatusAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(transfer.IdempotencyKey) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
|
||||||
|
}
|
||||||
|
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
t.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.logger.Debug("transfer created", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) {
|
||||||
|
transferRef = strings.TrimSpace(transferRef)
|
||||||
|
if transferRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
|
||||||
|
}
|
||||||
|
transfer := &model.Transfer{}
|
||||||
|
if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||||
|
query := repository.Query()
|
||||||
|
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {
|
||||||
|
query = query.Filter(repository.Field("sourceWalletRef"), src)
|
||||||
|
}
|
||||||
|
if dst := strings.TrimSpace(filter.DestinationWalletRef); dst != "" {
|
||||||
|
query = query.Filter(repository.Field("destination.managedWalletRef"), dst)
|
||||||
|
}
|
||||||
|
if status := strings.TrimSpace(string(filter.Status)); status != "" {
|
||||||
|
query = query.Filter(repository.Field("status"), status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||||
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
|
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||||
|
} else {
|
||||||
|
t.logger.Warn("ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := sanitizeTransferLimit(filter.Limit)
|
||||||
|
fetchLimit := limit + 1
|
||||||
|
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||||
|
|
||||||
|
transfers := make([]*model.Transfer, 0, fetchLimit)
|
||||||
|
decoder := func(cur *mongo.Cursor) error {
|
||||||
|
item := &model.Transfer{}
|
||||||
|
if err := cur.Decode(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transfers = append(transfers, item)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor := ""
|
||||||
|
if int64(len(transfers)) == fetchLimit {
|
||||||
|
last := transfers[len(transfers)-1]
|
||||||
|
nextCursor = last.ID.Hex()
|
||||||
|
transfers = transfers[:len(transfers)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.TransferList{
|
||||||
|
Items: transfers,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) {
|
||||||
|
transferRef = strings.TrimSpace(transferRef)
|
||||||
|
if transferRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
|
||||||
|
}
|
||||||
|
transfer := &model.Transfer{}
|
||||||
|
if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer.Status = status
|
||||||
|
if status == model.TransferStatusFailed {
|
||||||
|
transfer.FailureReason = strings.TrimSpace(failureReason)
|
||||||
|
} else {
|
||||||
|
transfer.FailureReason = ""
|
||||||
|
}
|
||||||
|
if hash := strings.TrimSpace(txHash); hash != "" {
|
||||||
|
transfer.TxHash = strings.ToLower(hash)
|
||||||
|
}
|
||||||
|
transfer.LastStatusAt = time.Now().UTC()
|
||||||
|
if err := t.repo.Update(ctx, transfer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeTransferLimit(requested int32) int64 {
|
||||||
|
if requested <= 0 {
|
||||||
|
return defaultTransferPageSize
|
||||||
|
}
|
||||||
|
if requested > int32(maxTransferPageSize) {
|
||||||
|
return maxTransferPageSize
|
||||||
|
}
|
||||||
|
return int64(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.TransfersStore = (*Transfers)(nil)
|
||||||
236
api/chain/gateway/storage/mongo/store/wallets.go
Normal file
236
api/chain/gateway/storage/mongo/store/wallets.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage"
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultWalletPageSize int64 = 50
|
||||||
|
maxWalletPageSize int64 = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
type Wallets struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
walletRepo repository.Repository
|
||||||
|
balanceRepo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWallets constructs a Mongo-backed wallets store.
|
||||||
|
func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo database is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
walletRepo := repository.CreateMongoRepository(db, mservice.ChainWallets)
|
||||||
|
walletIndexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "depositAddress", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "ownerRef", Sort: ri.Asc}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, def := range walletIndexes {
|
||||||
|
if err := walletRepo.CreateIndex(def); err != nil {
|
||||||
|
logger.Error("failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
balanceRepo := repository.CreateMongoRepository(db, mservice.ChainWalletBalances)
|
||||||
|
balanceIndexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, def := range balanceIndexes {
|
||||||
|
if err := balanceRepo.CreateIndex(def); err != nil {
|
||||||
|
logger.Error("failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
childLogger := logger.Named("wallets")
|
||||||
|
childLogger.Debug("wallet stores initialised")
|
||||||
|
|
||||||
|
return &Wallets{
|
||||||
|
logger: childLogger,
|
||||||
|
walletRepo: walletRepo,
|
||||||
|
balanceRepo: balanceRepo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: nil wallet")
|
||||||
|
}
|
||||||
|
wallet.Normalize()
|
||||||
|
if strings.TrimSpace(wallet.WalletRef) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
|
}
|
||||||
|
if wallet.Status == "" {
|
||||||
|
wallet.Status = model.ManagedWalletStatusActive
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||||
|
}
|
||||||
|
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||||
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
|
if walletRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
|
}
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||||
|
query := repository.Query()
|
||||||
|
|
||||||
|
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
||||||
|
query = query.Filter(repository.Field("organizationRef"), org)
|
||||||
|
}
|
||||||
|
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
||||||
|
query = query.Filter(repository.Field("ownerRef"), owner)
|
||||||
|
}
|
||||||
|
if network := strings.TrimSpace(filter.Network); network != "" {
|
||||||
|
query = query.Filter(repository.Field("network"), strings.ToLower(network))
|
||||||
|
}
|
||||||
|
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
||||||
|
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||||
|
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||||
|
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||||
|
} else {
|
||||||
|
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := sanitizeWalletLimit(filter.Limit)
|
||||||
|
fetchLimit := limit + 1
|
||||||
|
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||||
|
|
||||||
|
wallets := make([]*model.ManagedWallet, 0, fetchLimit)
|
||||||
|
decoder := func(cur *mongo.Cursor) error {
|
||||||
|
item := &model.ManagedWallet{}
|
||||||
|
if err := cur.Decode(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wallets = append(wallets, item)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor := ""
|
||||||
|
if int64(len(wallets)) == fetchLimit {
|
||||||
|
last := wallets[len(wallets)-1]
|
||||||
|
nextCursor = last.ID.Hex()
|
||||||
|
wallets = wallets[:len(wallets)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.ManagedWalletList{
|
||||||
|
Items: wallets,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
||||||
|
if balance == nil {
|
||||||
|
return merrors.InvalidArgument("walletsStore: nil balance")
|
||||||
|
}
|
||||||
|
balance.Normalize()
|
||||||
|
if strings.TrimSpace(balance.WalletRef) == "" {
|
||||||
|
return merrors.InvalidArgument("walletsStore: empty walletRef for balance")
|
||||||
|
}
|
||||||
|
if balance.CalculatedAt.IsZero() {
|
||||||
|
balance.CalculatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
existing := &model.WalletBalance{}
|
||||||
|
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
existing.Available = balance.Available
|
||||||
|
existing.PendingInbound = balance.PendingInbound
|
||||||
|
existing.PendingOutbound = balance.PendingOutbound
|
||||||
|
existing.CalculatedAt = balance.CalculatedAt
|
||||||
|
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||||
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
|
if walletRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||||
|
}
|
||||||
|
balance := &model.WalletBalance{}
|
||||||
|
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeWalletLimit(requested int32) int64 {
|
||||||
|
if requested <= 0 {
|
||||||
|
return defaultWalletPageSize
|
||||||
|
}
|
||||||
|
if requested > int32(maxWalletPageSize) {
|
||||||
|
return maxWalletPageSize
|
||||||
|
}
|
||||||
|
return int64(requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.WalletsStore = (*Wallets)(nil)
|
||||||
53
api/chain/gateway/storage/storage.go
Normal file
53
api/chain/gateway/storage/storage.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/chain/gateway/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageError string
|
||||||
|
|
||||||
|
func (e storageError) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrWalletNotFound indicates that a wallet record was not found.
|
||||||
|
ErrWalletNotFound = storageError("chain.gateway.storage: wallet not found")
|
||||||
|
// ErrTransferNotFound indicates that a transfer record was not found.
|
||||||
|
ErrTransferNotFound = storageError("chain.gateway.storage: transfer not found")
|
||||||
|
// ErrDepositNotFound indicates that a deposit record was not found.
|
||||||
|
ErrDepositNotFound = storageError("chain.gateway.storage: deposit not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository represents the root storage contract for the chain gateway module.
|
||||||
|
type Repository interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
Wallets() WalletsStore
|
||||||
|
Transfers() TransfersStore
|
||||||
|
Deposits() DepositsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletsStore exposes persistence operations for managed wallets.
|
||||||
|
type WalletsStore interface {
|
||||||
|
Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error)
|
||||||
|
Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error)
|
||||||
|
List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error)
|
||||||
|
SaveBalance(ctx context.Context, balance *model.WalletBalance) error
|
||||||
|
GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransfersStore exposes persistence operations for transfers.
|
||||||
|
type TransfersStore interface {
|
||||||
|
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
|
||||||
|
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
|
||||||
|
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
|
||||||
|
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DepositsStore exposes persistence operations for observed deposits.
|
||||||
|
type DepositsStore interface {
|
||||||
|
Record(ctx context.Context, deposit *model.Deposit) error
|
||||||
|
ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error)
|
||||||
|
}
|
||||||
BIN
api/fx/ingestor/.DS_Store
vendored
Normal file
BIN
api/fx/ingestor/.DS_Store
vendored
Normal file
Binary file not shown.
32
api/fx/ingestor/.air.toml
Normal file
32
api/fx/ingestor/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Config file for Air in TOML format
|
||||||
|
|
||||||
|
root = "./../.."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildDate=$(date)'\""
|
||||||
|
bin = "./app"
|
||||||
|
full_bin = "./app --debug --config.file=config.yml"
|
||||||
|
include_ext = ["go", "yaml", "yml"]
|
||||||
|
exclude_dir = ["fx/ingestor/tmp", "pkg/.git", "fx/ingestor/env"]
|
||||||
|
exclude_regex = ["_test\\.go"]
|
||||||
|
exclude_unchanged = true
|
||||||
|
follow_symlink = true
|
||||||
|
log = "air.log"
|
||||||
|
delay = 0
|
||||||
|
stop_on_error = true
|
||||||
|
send_interrupt = true
|
||||||
|
kill_delay = 500
|
||||||
|
args_bin = []
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
main = "magenta"
|
||||||
|
watcher = "cyan"
|
||||||
|
build = "yellow"
|
||||||
|
runner = "green"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
3
api/fx/ingestor/.gitignore
vendored
Normal file
3
api/fx/ingestor/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
43
api/fx/ingestor/config.yml
Normal file
43
api/fx/ingestor/config.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
poll_interval_seconds: 30
|
||||||
|
|
||||||
|
market:
|
||||||
|
sources:
|
||||||
|
- driver: BINANCE
|
||||||
|
settings:
|
||||||
|
base_url: "https://api.binance.com"
|
||||||
|
- driver: COINGECKO
|
||||||
|
settings:
|
||||||
|
base_url: "https://api.coingecko.com/api/v3"
|
||||||
|
pairs:
|
||||||
|
BINANCE:
|
||||||
|
- base: "USDT"
|
||||||
|
quote: "EUR"
|
||||||
|
symbol: "EURUSDT"
|
||||||
|
invert: true
|
||||||
|
- base: "UAH"
|
||||||
|
quote: "USDT"
|
||||||
|
symbol: "USDTUAH"
|
||||||
|
invert: true
|
||||||
|
- base: "USDC"
|
||||||
|
quote: "EUR"
|
||||||
|
symbol: "EURUSDC"
|
||||||
|
invert: true
|
||||||
|
COINGECKO:
|
||||||
|
- base: "USDT"
|
||||||
|
quote: "RUB"
|
||||||
|
symbol: "tether:rub"
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
address: ":9102"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: FX_MONGO_HOST
|
||||||
|
port_env: FX_MONGO_PORT
|
||||||
|
database_env: FX_MONGO_DATABASE
|
||||||
|
user_env: FX_MONGO_USER
|
||||||
|
password_env: FX_MONGO_PASSWORD
|
||||||
|
auth_source_env: FX_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: FX_MONGO_REPLICA_SET
|
||||||
1
api/fx/ingestor/env/.gitignore
vendored
Normal file
1
api/fx/ingestor/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env.api
|
||||||
55
api/fx/ingestor/go.mod
Normal file
55
api/fx/ingestor/go.mod
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
module github.com/tech/sendico/fx/ingestor
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/fx/storage => ../storage
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/tech/sendico/fx/storage v0.0.0
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
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.132.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.1 // 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.47.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.11 // 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.2 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // 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.43.0 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||||
|
google.golang.org/grpc v1.76.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
225
api/fx/ingestor/go.sum
Normal file
225
api/fx/ingestor/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.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||||
|
github.com/casbin/casbin/v2 v2.132.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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
|
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.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||||
|
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||||
|
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||||
|
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.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||||
|
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||||
|
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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.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.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
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-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/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/fx/ingestor/internal/appversion/version.go
Normal file
27
api/fx/ingestor/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: "MeetX Connectica FX Ingestor Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&vi)
|
||||||
|
}
|
||||||
147
api/fx/ingestor/internal/config/config.go
Normal file
147
api/fx/ingestor/internal/config/config.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPollInterval = 30 * time.Second
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||||
|
Market MarketConfig `yaml:"market"`
|
||||||
|
Database *db.Config `yaml:"database"`
|
||||||
|
Metrics *MetricsConfig `yaml:"metrics"`
|
||||||
|
|
||||||
|
pairs []Pair
|
||||||
|
pairsBySource map[mmodel.Driver][]PairConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmerrors.New("config: path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("config: failed to read file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Market.Sources) == 0 {
|
||||||
|
return nil, fmerrors.New("config: no market sources configured")
|
||||||
|
}
|
||||||
|
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
||||||
|
for idx := range cfg.Market.Sources {
|
||||||
|
src := &cfg.Market.Sources[idx]
|
||||||
|
if src.Driver.IsEmpty() {
|
||||||
|
return nil, fmerrors.New("config: market source driver is empty")
|
||||||
|
}
|
||||||
|
sourceSet[src.Driver] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Market.Pairs) == 0 {
|
||||||
|
return nil, fmerrors.New("config: no pairs configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
||||||
|
pairsBySource := make(map[mmodel.Driver][]PairConfig, len(cfg.Market.Pairs))
|
||||||
|
var flattened []Pair
|
||||||
|
|
||||||
|
for rawSource, pairList := range cfg.Market.Pairs {
|
||||||
|
driver := mmodel.Driver(rawSource)
|
||||||
|
if driver.IsEmpty() {
|
||||||
|
return nil, fmerrors.New("config: pair source is empty")
|
||||||
|
}
|
||||||
|
if _, ok := sourceSet[driver]; !ok {
|
||||||
|
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := make([]PairConfig, len(pairList))
|
||||||
|
for idx := range pairList {
|
||||||
|
pair := pairList[idx]
|
||||||
|
pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base))
|
||||||
|
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
||||||
|
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
||||||
|
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
||||||
|
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pair.Provider) == "" {
|
||||||
|
pair.Provider = strings.ToLower(driver.String())
|
||||||
|
}
|
||||||
|
processed[idx] = pair
|
||||||
|
flattened = append(flattened, Pair{
|
||||||
|
PairConfig: pair,
|
||||||
|
Source: driver,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pairsBySource[driver] = processed
|
||||||
|
normalizedPairs[driver.String()] = processed
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Market.Pairs = normalizedPairs
|
||||||
|
cfg.pairsBySource = pairsBySource
|
||||||
|
cfg.pairs = flattened
|
||||||
|
if cfg.Database == nil {
|
||||||
|
return nil, fmerrors.New("config: database configuration is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
||||||
|
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
|
||||||
|
if cfg.Metrics.Address == "" {
|
||||||
|
cfg.Metrics.Address = ":9102"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) PollInterval() time.Duration {
|
||||||
|
if c == nil {
|
||||||
|
return defaultPollInterval
|
||||||
|
}
|
||||||
|
if c.PollIntervalSeconds <= 0 {
|
||||||
|
return defaultPollInterval
|
||||||
|
}
|
||||||
|
return time.Duration(c.PollIntervalSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Pairs() []Pair {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]Pair, len(c.pairs))
|
||||||
|
copy(out, c.pairs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) PairsBySource() map[mmodel.Driver][]PairConfig {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource))
|
||||||
|
for driver, pairs := range c.pairsBySource {
|
||||||
|
cp := make([]PairConfig, len(pairs))
|
||||||
|
copy(cp, pairs)
|
||||||
|
out[driver] = cp
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) MetricsConfig() *MetricsConfig {
|
||||||
|
if c == nil || c.Metrics == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *c.Metrics
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
24
api/fx/ingestor/internal/config/market.go
Normal file
24
api/fx/ingestor/internal/config/market.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
pmodel "github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PairConfig struct {
|
||||||
|
Base string `yaml:"base"`
|
||||||
|
Quote string `yaml:"quote"`
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
Provider string `yaml:"provider"`
|
||||||
|
Invert bool `yaml:"invert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pair struct {
|
||||||
|
PairConfig `yaml:",inline"`
|
||||||
|
Source mmodel.Driver `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketConfig struct {
|
||||||
|
Sources []pmodel.DriverConfig[mmodel.Driver] `yaml:"sources"`
|
||||||
|
Pairs map[string][]PairConfig `yaml:"pairs"`
|
||||||
|
}
|
||||||
6
api/fx/ingestor/internal/config/metrics.go
Normal file
6
api/fx/ingestor/internal/config/metrics.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type MetricsConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
}
|
||||||
35
api/fx/ingestor/internal/fmerrors/market.go
Normal file
35
api/fx/ingestor/internal/fmerrors/market.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package fmerrors
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
message string
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.cause == nil {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
return e.message + ": " + e.cause.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(message string) error {
|
||||||
|
return &Error{message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Wrap(message string, cause error) error {
|
||||||
|
return &Error{message: message, cause: cause}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDecimal(value string) error {
|
||||||
|
return &Error{message: "invalid decimal \"" + value + "\""}
|
||||||
|
}
|
||||||
84
api/fx/ingestor/internal/ingestor/metrics.go
Normal file
84
api/fx/ingestor/internal/ingestor/metrics.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package ingestor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceMetrics struct {
|
||||||
|
pollDuration *prometheus.HistogramVec
|
||||||
|
pollTotal *prometheus.CounterVec
|
||||||
|
pairDuration *prometheus.HistogramVec
|
||||||
|
pairTotal *prometheus.CounterVec
|
||||||
|
pairLastUpdate *prometheus.GaugeVec
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
globalMetricsRef *serviceMetrics
|
||||||
|
)
|
||||||
|
|
||||||
|
func getServiceMetrics() *serviceMetrics {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
reg := prometheus.DefaultRegisterer
|
||||||
|
globalMetricsRef = &serviceMetrics{
|
||||||
|
pollDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "fx_ingestor_poll_duration_seconds",
|
||||||
|
Help: "Duration of a polling cycle.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"result"}),
|
||||||
|
pollTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "fx_ingestor_poll_total",
|
||||||
|
Help: "Total polling cycles executed.",
|
||||||
|
}, []string{"result"}),
|
||||||
|
pairDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "fx_ingestor_pair_duration_seconds",
|
||||||
|
Help: "Duration of individual pair ingestion.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"source", "provider", "symbol", "result"}),
|
||||||
|
pairTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "fx_ingestor_pair_total",
|
||||||
|
Help: "Total ingestion attempts per pair.",
|
||||||
|
}, []string{"source", "provider", "symbol", "result"}),
|
||||||
|
pairLastUpdate: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Name: "fx_ingestor_pair_last_success_unix",
|
||||||
|
Help: "Unix timestamp of the last successful ingestion per pair.",
|
||||||
|
}, []string{"source", "provider", "symbol"}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalMetricsRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *serviceMetrics) observePoll(duration time.Duration, err error) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := labelForError(err)
|
||||||
|
m.pollDuration.WithLabelValues(result).Observe(duration.Seconds())
|
||||||
|
m.pollTotal.WithLabelValues(result).Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *serviceMetrics) observePair(pair config.Pair, duration time.Duration, err error) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := labelForError(err)
|
||||||
|
labels := []string{pair.Source.String(), pair.Provider, pair.Symbol, result}
|
||||||
|
m.pairDuration.WithLabelValues(labels...).Observe(duration.Seconds())
|
||||||
|
m.pairTotal.WithLabelValues(labels...).Inc()
|
||||||
|
if err == nil {
|
||||||
|
m.pairLastUpdate.WithLabelValues(pair.Source.String(), pair.Provider, pair.Symbol).
|
||||||
|
Set(float64(time.Now().Unix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelForError(err error) string {
|
||||||
|
if err != nil {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
207
api/fx/ingestor/internal/ingestor/service.go
Normal file
207
api/fx/ingestor/internal/ingestor/service.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
package ingestor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/fx/storage"
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
cfg *config.Config
|
||||||
|
rates storage.RatesStore
|
||||||
|
pairs []config.Pair
|
||||||
|
connectors map[mmodel.Driver]mmodel.Connector
|
||||||
|
metrics *serviceMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, fmerrors.New("ingestor: nil logger")
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmerrors.New("ingestor: nil config")
|
||||||
|
}
|
||||||
|
if repo == nil {
|
||||||
|
return nil, fmerrors.New("ingestor: nil repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("build connectors", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
logger: logger.Named("ingestor"),
|
||||||
|
cfg: cfg,
|
||||||
|
rates: repo.Rates(),
|
||||||
|
pairs: cfg.Pairs(),
|
||||||
|
connectors: connectors,
|
||||||
|
metrics: getServiceMetrics(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Run(ctx context.Context) error {
|
||||||
|
interval := s.cfg.PollInterval()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
s.logger.Info("FX ingestion service started", zap.Duration("poll_interval", interval), zap.Int("pairs", len(s.pairs)))
|
||||||
|
|
||||||
|
if err := s.executePoll(ctx); err != nil {
|
||||||
|
s.logger.Warn("Initial poll completed with errors", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.logger.Info("Context cancelled, stopping ingestor")
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := s.executePoll(ctx); err != nil {
|
||||||
|
s.logger.Warn("Polling cycle completed with errors", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) executePoll(ctx context.Context) error {
|
||||||
|
start := time.Now()
|
||||||
|
err := s.pollOnce(ctx)
|
||||||
|
if s.metrics != nil {
|
||||||
|
s.metrics.observePoll(time.Since(start), err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pollOnce(ctx context.Context) error {
|
||||||
|
var firstErr error
|
||||||
|
for _, pair := range s.pairs {
|
||||||
|
start := time.Now()
|
||||||
|
err := s.upsertPair(ctx, pair)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if s.metrics != nil {
|
||||||
|
s.metrics.observePair(pair, elapsed, err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
s.logger.Warn("Failed to ingest pair",
|
||||||
|
zap.String("symbol", pair.Symbol),
|
||||||
|
zap.String("source", pair.Source.String()),
|
||||||
|
zap.Duration("elapsed", elapsed),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||||
|
connector, ok := s.connectors[pair.Source]
|
||||||
|
if !ok {
|
||||||
|
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||||
|
if err != nil {
|
||||||
|
return fmerrors.Wrap("fetch ticker", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bid, err := parseDecimal(ticker.BidPrice)
|
||||||
|
if err != nil {
|
||||||
|
return fmerrors.Wrap("parse bid price", err)
|
||||||
|
}
|
||||||
|
ask, err := parseDecimal(ticker.AskPrice)
|
||||||
|
if err != nil {
|
||||||
|
return fmerrors.Wrap("parse ask price", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair.Invert {
|
||||||
|
bid, ask = invertPrices(bid, ask)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ask.Cmp(bid) < 0 {
|
||||||
|
// Ensure bid <= ask to keep downstream logic predictable.
|
||||||
|
bid, ask = ask, bid
|
||||||
|
}
|
||||||
|
|
||||||
|
mid := new(big.Rat).Add(bid, ask)
|
||||||
|
mid.Quo(mid, big.NewRat(2, 1))
|
||||||
|
|
||||||
|
spread := big.NewRat(0, 1)
|
||||||
|
if mid.Sign() != 0 {
|
||||||
|
spread.Sub(ask, bid)
|
||||||
|
if spread.Sign() < 0 {
|
||||||
|
spread.Neg(spread)
|
||||||
|
}
|
||||||
|
spread.Quo(spread, mid)
|
||||||
|
spread.Mul(spread, big.NewRat(10000, 1)) // basis points
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
asOf := now
|
||||||
|
snapshot := &model.RateSnapshot{
|
||||||
|
RateRef: market.BuildRateReference(pair.Provider, pair.Symbol, now),
|
||||||
|
Pair: model.CurrencyPair{Base: pair.Base, Quote: pair.Quote},
|
||||||
|
Provider: pair.Provider,
|
||||||
|
Mid: formatDecimal(mid),
|
||||||
|
Bid: formatDecimal(bid),
|
||||||
|
Ask: formatDecimal(ask),
|
||||||
|
SpreadBps: formatDecimal(spread),
|
||||||
|
AsOfUnixMs: now.UnixMilli(),
|
||||||
|
AsOf: &asOf,
|
||||||
|
Source: ticker.Provider,
|
||||||
|
ProviderRef: ticker.Symbol,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||||
|
return fmerrors.Wrap("upsert snapshot", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Debug("Snapshot ingested",
|
||||||
|
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||||
|
zap.String("provider", pair.Provider),
|
||||||
|
zap.String("bid", snapshot.Bid),
|
||||||
|
zap.String("ask", snapshot.Ask),
|
||||||
|
zap.String("mid", snapshot.Mid),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDecimal(value string) (*big.Rat, error) {
|
||||||
|
r := new(big.Rat)
|
||||||
|
if _, ok := r.SetString(value); !ok {
|
||||||
|
return nil, fmerrors.NewDecimal(value)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func invertPrices(bid, ask *big.Rat) (*big.Rat, *big.Rat) {
|
||||||
|
if bid.Sign() == 0 || ask.Sign() == 0 {
|
||||||
|
return bid, ask
|
||||||
|
}
|
||||||
|
one := big.NewRat(1, 1)
|
||||||
|
invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid
|
||||||
|
invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask
|
||||||
|
return invBid, invAsk
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDecimal(r *big.Rat) string {
|
||||||
|
if r == nil {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
// Format with 8 decimal places, trimming trailing zeros.
|
||||||
|
return r.FloatString(8)
|
||||||
|
}
|
||||||
237
api/fx/ingestor/internal/ingestor/service_test.go
Normal file
237
api/fx/ingestor/internal/ingestor/service_test.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package ingestor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/fx/storage"
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDecimal(t *testing.T) {
|
||||||
|
got, err := parseDecimal("123.456")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDecimal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.String() != "15432/125" { // 123.456 expressed as a rational
|
||||||
|
t.Fatalf("unexpected rational value: %s", got.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseDecimal("not-a-number"); err == nil {
|
||||||
|
t.Fatalf("parseDecimal should fail on invalid decimal string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvertPrices(t *testing.T) {
|
||||||
|
bid, err := parseDecimal("2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDecimal: %v", err)
|
||||||
|
}
|
||||||
|
ask, err := parseDecimal("4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDecimal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
invBid, invAsk := invertPrices(bid, ask)
|
||||||
|
if diff := cmp.Diff("0.5", invAsk.FloatString(1)); diff != "" {
|
||||||
|
t.Fatalf("unexpected inverted ask (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff("0.25", invBid.FloatString(2)); diff != "" {
|
||||||
|
t.Fatalf("unexpected inverted bid (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUpsertPairStoresSnapshot(t *testing.T) {
|
||||||
|
store := &ratesStoreStub{}
|
||||||
|
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||||
|
mmarket.DriverBinance: &connectorStub{
|
||||||
|
id: mmarket.DriverBinance,
|
||||||
|
ticker: &mmarket.Ticker{
|
||||||
|
Symbol: "EURUSDT",
|
||||||
|
BidPrice: "1.0000",
|
||||||
|
AskPrice: "1.2000",
|
||||||
|
Provider: "binance",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
pair := config.Pair{
|
||||||
|
PairConfig: config.PairConfig{
|
||||||
|
Base: "USDT",
|
||||||
|
Quote: "EUR",
|
||||||
|
Symbol: "EURUSDT",
|
||||||
|
Provider: "binance",
|
||||||
|
},
|
||||||
|
Source: mmarket.DriverBinance,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.upsertPair(context.Background(), pair); err != nil {
|
||||||
|
t.Fatalf("upsertPair returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(store.snapshots) != 1 {
|
||||||
|
t.Fatalf("expected 1 snapshot stored, got %d", len(store.snapshots))
|
||||||
|
}
|
||||||
|
snap := store.snapshots[0]
|
||||||
|
if snap.Pair.Base != "USDT" || snap.Pair.Quote != "EUR" {
|
||||||
|
t.Fatalf("unexpected currency pair stored: %+v", snap.Pair)
|
||||||
|
}
|
||||||
|
if snap.Provider != "binance" {
|
||||||
|
t.Fatalf("unexpected provider: %s", snap.Provider)
|
||||||
|
}
|
||||||
|
if snap.Bid != "1.00000000" || snap.Ask != "1.20000000" {
|
||||||
|
t.Fatalf("unexpected bid/ask: %s / %s", snap.Bid, snap.Ask)
|
||||||
|
}
|
||||||
|
if snap.Mid != "1.10000000" {
|
||||||
|
t.Fatalf("unexpected mid price: %s", snap.Mid)
|
||||||
|
}
|
||||||
|
if snap.SpreadBps != "1818.18181818" {
|
||||||
|
t.Fatalf("unexpected spread bps: %s", snap.SpreadBps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUpsertPairInvertsPrices(t *testing.T) {
|
||||||
|
store := &ratesStoreStub{}
|
||||||
|
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||||
|
mmarket.DriverCoinGecko: &connectorStub{
|
||||||
|
id: mmarket.DriverCoinGecko,
|
||||||
|
ticker: &mmarket.Ticker{
|
||||||
|
Symbol: "RUBUSDT",
|
||||||
|
BidPrice: "2",
|
||||||
|
AskPrice: "4",
|
||||||
|
Provider: "coingecko",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
pair := config.Pair{
|
||||||
|
PairConfig: config.PairConfig{
|
||||||
|
Base: "RUB",
|
||||||
|
Quote: "USDT",
|
||||||
|
Symbol: "RUBUSDT",
|
||||||
|
Provider: "coingecko",
|
||||||
|
Invert: true,
|
||||||
|
},
|
||||||
|
Source: mmarket.DriverCoinGecko,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.upsertPair(context.Background(), pair); err != nil {
|
||||||
|
t.Fatalf("upsertPair returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := store.snapshots[0]
|
||||||
|
if snap.Bid != "0.25000000" || snap.Ask != "0.50000000" {
|
||||||
|
t.Fatalf("unexpected inverted bid/ask: %s / %s", snap.Bid, snap.Ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
||||||
|
errFetch := fmerrors.New("fetch failed")
|
||||||
|
connectorSuccess := &connectorStub{
|
||||||
|
id: mmarket.DriverBinance,
|
||||||
|
ticker: &mmarket.Ticker{
|
||||||
|
Symbol: "EURUSDT",
|
||||||
|
BidPrice: "1",
|
||||||
|
AskPrice: "1",
|
||||||
|
Provider: "binance",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
connectorFail := &connectorStub{
|
||||||
|
id: mmarket.DriverCoinGecko,
|
||||||
|
err: errFetch,
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &ratesStoreStub{}
|
||||||
|
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||||
|
mmarket.DriverBinance: connectorSuccess,
|
||||||
|
mmarket.DriverCoinGecko: connectorFail,
|
||||||
|
})
|
||||||
|
svc.pairs = []config.Pair{
|
||||||
|
{PairConfig: config.PairConfig{Base: "USDT", Quote: "EUR", Symbol: "EURUSDT"}, Source: mmarket.DriverBinance},
|
||||||
|
{PairConfig: config.PairConfig{Base: "USDT", Quote: "RUB", Symbol: "RUBUSDT"}, Source: mmarket.DriverCoinGecko},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.pollOnce(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("pollOnce expected to return error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errFetch) {
|
||||||
|
t.Fatalf("pollOnce returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if connectorSuccess.calls != 1 {
|
||||||
|
t.Fatalf("expected success connector called once, got %d", connectorSuccess.calls)
|
||||||
|
}
|
||||||
|
if connectorFail.calls != 1 {
|
||||||
|
t.Fatalf("expected failing connector called once, got %d", connectorFail.calls)
|
||||||
|
}
|
||||||
|
if len(store.snapshots) != 1 {
|
||||||
|
t.Fatalf("expected snapshot stored only for successful pair, got %d", len(store.snapshots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- test helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
type ratesStoreStub struct {
|
||||||
|
snapshots []*model.RateSnapshot
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ratesStoreStub) UpsertSnapshot(_ context.Context, snapshot *model.RateSnapshot) error {
|
||||||
|
if r.err != nil {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
cp := *snapshot
|
||||||
|
r.snapshots = append(r.snapshots, &cp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ratesStoreStub) LatestSnapshot(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryStub struct {
|
||||||
|
rates storage.RatesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryStub) Ping(context.Context) error { return nil }
|
||||||
|
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
|
||||||
|
func (r *repositoryStub) Quotes() storage.QuotesStore { return nil }
|
||||||
|
func (r *repositoryStub) Pairs() storage.PairStore { return nil }
|
||||||
|
func (r *repositoryStub) Currencies() storage.CurrencyStore { return nil }
|
||||||
|
|
||||||
|
type connectorStub struct {
|
||||||
|
id mmarket.Driver
|
||||||
|
ticker *mmarket.Ticker
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorStub) ID() mmarket.Driver {
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorStub) FetchTicker(_ context.Context, symbol string) (*mmarket.Ticker, error) {
|
||||||
|
c.calls++
|
||||||
|
if c.ticker != nil {
|
||||||
|
cp := *c.ticker
|
||||||
|
cp.Symbol = symbol
|
||||||
|
return &cp, c.err
|
||||||
|
}
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func testService(store storage.RatesStore, connectors map[mmarket.Driver]mmarket.Connector) *Service {
|
||||||
|
return &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
cfg: &config.Config{},
|
||||||
|
rates: store,
|
||||||
|
connectors: connectors,
|
||||||
|
pairs: nil,
|
||||||
|
metrics: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
139
api/fx/ingestor/internal/market/binance/connector.go
Normal file
139
api/fx/ingestor/internal/market/binance/connector.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package binance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type binanceConnector struct {
|
||||||
|
id mmodel.Driver
|
||||||
|
provider string
|
||||||
|
client *http.Client
|
||||||
|
base string
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBinanceBaseURL = "https://api.binance.com"
|
||||||
|
const (
|
||||||
|
defaultDialTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||||
|
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||||
|
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||||
|
baseURL := defaultBinanceBaseURL
|
||||||
|
provider := strings.ToLower(mmodel.DriverBinance.String())
|
||||||
|
dialTimeout := defaultDialTimeoutSeconds
|
||||||
|
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||||
|
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||||
|
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||||
|
requestTimeout := defaultRequestTimeoutSeconds
|
||||||
|
|
||||||
|
if settings != nil {
|
||||||
|
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
baseURL = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
provider = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||||
|
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||||
|
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||||
|
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||||
|
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("binance: invalid base url", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||||
|
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||||
|
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := &binanceConnector{
|
||||||
|
id: mmodel.DriverBinance,
|
||||||
|
provider: provider,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: requestTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
},
|
||||||
|
base: parsed.String(),
|
||||||
|
logger: logger.Named("binance"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return connector, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *binanceConnector) ID() mmodel.Driver {
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||||
|
if strings.TrimSpace(symbol) == "" {
|
||||||
|
return nil, fmerrors.New("binance: symbol is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := url.Parse(c.base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("binance: parse base url", err)
|
||||||
|
}
|
||||||
|
endpoint.Path = "/api/v3/ticker/bookTicker"
|
||||||
|
query := endpoint.Query()
|
||||||
|
query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol)))
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("binance: build request", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
|
return nil, fmerrors.Wrap("binance: request failed", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||||
|
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
BidPrice string `json:"bidPrice"`
|
||||||
|
AskPrice string `json:"askPrice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
|
return nil, fmerrors.Wrap("binance: decode response", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mmodel.Ticker{
|
||||||
|
Symbol: payload.Symbol,
|
||||||
|
BidPrice: payload.BidPrice,
|
||||||
|
AskPrice: payload.AskPrice,
|
||||||
|
Provider: c.provider,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
222
api/fx/ingestor/internal/market/coingecko/connector.go
Normal file
222
api/fx/ingestor/internal/market/coingecko/connector.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package coingecko
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type coingeckoConnector struct {
|
||||||
|
id mmodel.Driver
|
||||||
|
provider string
|
||||||
|
client *http.Client
|
||||||
|
base string
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDialTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||||
|
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||||
|
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||||
|
baseURL := defaultCoinGeckoBaseURL
|
||||||
|
provider := strings.ToLower(mmodel.DriverCoinGecko.String())
|
||||||
|
dialTimeout := defaultDialTimeoutSeconds
|
||||||
|
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||||
|
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||||
|
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||||
|
requestTimeout := defaultRequestTimeoutSeconds
|
||||||
|
|
||||||
|
if settings != nil {
|
||||||
|
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
baseURL = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
provider = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||||
|
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||||
|
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||||
|
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||||
|
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||||
|
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||||
|
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := &coingeckoConnector{
|
||||||
|
id: mmodel.DriverCoinGecko,
|
||||||
|
provider: provider,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: requestTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
},
|
||||||
|
base: strings.TrimRight(parsed.String(), "/"),
|
||||||
|
logger: logger.Named("coingecko"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return connector, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *coingeckoConnector) ID() mmodel.Driver {
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||||
|
coinID, vsCurrency, err := parseSymbol(symbol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := url.Parse(c.base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("coingecko: parse base url", err)
|
||||||
|
}
|
||||||
|
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
||||||
|
query := endpoint.Query()
|
||||||
|
query.Set("ids", coinID)
|
||||||
|
query.Set("vs_currencies", vsCurrency)
|
||||||
|
query.Set("include_last_updated_at", "true")
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("coingecko: build request", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
|
return nil, fmerrors.Wrap("coingecko: request failed", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||||
|
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
decoder.UseNumber()
|
||||||
|
|
||||||
|
var payload map[string]map[string]interface{}
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
|
return nil, fmerrors.Wrap("coingecko: decode response", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coinData, ok := payload[coinID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmerrors.New("coingecko: coin id not found in response")
|
||||||
|
}
|
||||||
|
priceValue, ok := coinData[vsCurrency]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmerrors.New("coingecko: vs currency not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
price, ok := toFloat(priceValue)
|
||||||
|
if !ok || price <= 0 {
|
||||||
|
return nil, fmerrors.New("coingecko: invalid price value in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
||||||
|
|
||||||
|
timestamp := time.Now().UnixMilli()
|
||||||
|
if tsValue, ok := coinData["last_updated_at"]; ok {
|
||||||
|
if tsFloat, ok := toFloat(tsValue); ok && tsFloat > 0 {
|
||||||
|
tsMillis := int64(tsFloat * 1000)
|
||||||
|
if tsMillis > 0 {
|
||||||
|
timestamp = tsMillis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refSymbol := coinID + "_" + vsCurrency
|
||||||
|
|
||||||
|
return &mmodel.Ticker{
|
||||||
|
Symbol: refSymbol,
|
||||||
|
BidPrice: priceStr,
|
||||||
|
AskPrice: priceStr,
|
||||||
|
Provider: c.provider,
|
||||||
|
Timestamp: timestamp,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSymbol(symbol string) (string, string, error) {
|
||||||
|
trimmed := strings.TrimSpace(symbol)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", "", fmerrors.New("coingecko: symbol is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case ':', '/', '-', '_':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
|
||||||
|
}
|
||||||
|
|
||||||
|
coinID := strings.TrimSpace(parts[0])
|
||||||
|
vsCurrency := strings.TrimSpace(parts[1])
|
||||||
|
if coinID == "" || vsCurrency == "" {
|
||||||
|
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
|
||||||
|
}
|
||||||
|
|
||||||
|
return coinID, vsCurrency, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(value interface{}) (float64, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
f, err := v.Float64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case float32:
|
||||||
|
return float64(v), true
|
||||||
|
case int:
|
||||||
|
return float64(v), true
|
||||||
|
case int64:
|
||||||
|
return float64(v), true
|
||||||
|
case uint64:
|
||||||
|
return float64(v), true
|
||||||
|
case string:
|
||||||
|
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
46
api/fx/ingestor/internal/market/common/settings.go
Normal file
46
api/fx/ingestor/internal/market/common/settings.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid.
|
||||||
|
func DurationSetting(settings model.SettingsT, key string, def time.Duration) time.Duration {
|
||||||
|
if settings == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
value, ok := settings[key]
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := value.(type) {
|
||||||
|
case time.Duration:
|
||||||
|
if v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
if v > 0 {
|
||||||
|
return time.Duration(v) * time.Second
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if v > 0 {
|
||||||
|
return time.Duration(v) * time.Second
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if v > 0 {
|
||||||
|
return time.Duration(v * float64(time.Second))
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
if seconds, err := strconv.ParseFloat(v, 64); err == nil && seconds > 0 {
|
||||||
|
return time.Duration(seconds * float64(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
55
api/fx/ingestor/internal/market/factory.go
Normal file
55
api/fx/ingestor/internal/market/factory.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package market
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConnectorFactory func(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error)
|
||||||
|
|
||||||
|
func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.Driver]) (map[mmodel.Driver]mmodel.Connector, error) {
|
||||||
|
connectors := make(map[mmodel.Driver]mmodel.Connector, len(configs))
|
||||||
|
|
||||||
|
for _, cfg := range configs {
|
||||||
|
driver := mmodel.NormalizeDriver(cfg.Driver)
|
||||||
|
if driver.IsEmpty() {
|
||||||
|
return nil, fmerrors.New("market: connector driver is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
conn mmodel.Connector
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch driver {
|
||||||
|
case mmodel.DriverBinance:
|
||||||
|
conn, err = binance.NewConnector(logger, cfg.Settings)
|
||||||
|
case mmodel.DriverCoinGecko:
|
||||||
|
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
||||||
|
default:
|
||||||
|
err = fmerrors.New("market: unsupported driver " + driver.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
|
||||||
|
}
|
||||||
|
connectors[driver] = conn
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildRateReference(provider, symbol string, now time.Time) string {
|
||||||
|
if strings.TrimSpace(provider) == "" {
|
||||||
|
provider = "unknown"
|
||||||
|
}
|
||||||
|
return provider + ":" + symbol + ":" + strconv.FormatInt(now.UnixMilli(), 10)
|
||||||
|
}
|
||||||
134
api/fx/ingestor/internal/metrics/server.go
Normal file
134
api/fx/ingestor/internal/metrics/server.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAddress = ":9102"
|
||||||
|
readHeaderTimeout = 5 * time.Second
|
||||||
|
defaultShutdownWindow = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server interface {
|
||||||
|
SetStatus(health.ServiceStatus)
|
||||||
|
Close(context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, fmerrors.New("metrics: logger is nil")
|
||||||
|
}
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
logger.Debug("Metrics disabled; using noop server")
|
||||||
|
return noopServer{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
address := strings.TrimSpace(cfg.Address)
|
||||||
|
if address == "" {
|
||||||
|
address = defaultAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsLogger := logger.Named("metrics")
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
var healthRouter routers.Health
|
||||||
|
if hr, err := routers.NewHealthRouter(metricsLogger, router, ""); err != nil {
|
||||||
|
metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
hr.SetStatus(health.SSStarting)
|
||||||
|
healthRouter = hr
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: address,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
ms := &httpServerWrapper{
|
||||||
|
logger: metricsLogger,
|
||||||
|
server: httpServer,
|
||||||
|
health: healthRouter,
|
||||||
|
timeout: defaultShutdownWindow,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||||
|
if healthRouter != nil {
|
||||||
|
healthRouter.SetStatus(health.SSTerminating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpServerWrapper struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
server *http.Server
|
||||||
|
health routers.Health
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *httpServerWrapper) SetStatus(status health.ServiceStatus) {
|
||||||
|
if s == nil || s.health == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Debug("Updating metrics health status", zap.String("status", string(status)))
|
||||||
|
s.health.SetStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *httpServerWrapper) Close(ctx context.Context) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.health != nil {
|
||||||
|
s.health.SetStatus(health.SSTerminating)
|
||||||
|
s.health.Finish()
|
||||||
|
s.health = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCtx := ctx
|
||||||
|
if shutdownCtx == nil {
|
||||||
|
shutdownCtx = context.Background()
|
||||||
|
}
|
||||||
|
if s.timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
s.logger.Info("Metrics server stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopServer struct{}
|
||||||
|
|
||||||
|
func (noopServer) SetStatus(health.ServiceStatus) {}
|
||||||
|
|
||||||
|
func (noopServer) Close(context.Context) {}
|
||||||
30
api/fx/ingestor/internal/model/connector.go
Normal file
30
api/fx/ingestor/internal/model/connector.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Driver string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriverBinance Driver = "BINANCE"
|
||||||
|
DriverCoinGecko Driver = "COINGECKO"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d Driver) String() string {
|
||||||
|
return string(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Driver) IsEmpty() bool {
|
||||||
|
return strings.TrimSpace(string(d)) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeDriver(d Driver) Driver {
|
||||||
|
return Driver(strings.ToUpper(strings.TrimSpace(string(d))))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connector interface {
|
||||||
|
ID() Driver
|
||||||
|
FetchTicker(ctx context.Context, symbol string) (*Ticker, error)
|
||||||
|
}
|
||||||
9
api/fx/ingestor/internal/model/ticker.go
Normal file
9
api/fx/ingestor/internal/model/ticker.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Ticker struct {
|
||||||
|
Symbol string
|
||||||
|
BidPrice string
|
||||||
|
AskPrice string
|
||||||
|
Provider string
|
||||||
|
Timestamp int64
|
||||||
|
}
|
||||||
14
api/fx/ingestor/internal/signalctx/signalctx.go
Normal file
14
api/fx/ingestor/internal/signalctx/signalctx.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package signalctx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WithSignals(parent context.Context, sig ...os.Signal) (context.Context, context.CancelFunc) {
|
||||||
|
if parent == nil {
|
||||||
|
parent = context.Background()
|
||||||
|
}
|
||||||
|
return signal.NotifyContext(parent, sig...)
|
||||||
|
}
|
||||||
55
api/fx/ingestor/main.go
Normal file
55
api/fx/ingestor/main.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/app"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
|
||||||
|
lf "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configFile = flag.String("config.file", app.DefaultConfigPath, "Path to the configuration file.")
|
||||||
|
debugFlag = flag.Bool("debug", false, "Enable debug logging.")
|
||||||
|
versionFlag = flag.Bool("version", false, "Show version information.")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
|
||||||
|
defer logger.Sync()
|
||||||
|
|
||||||
|
av := appversion.Create()
|
||||||
|
if *versionFlag {
|
||||||
|
fmt.Fprintln(os.Stdout, av.Print())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info()))
|
||||||
|
|
||||||
|
ctx, cancel := signalctx.WithSignals(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
application, err := app.New(logger, *configFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to initialise application", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := application.Run(ctx); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
logger.Info("FX ingestor stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Fatal("Ingestor terminated with error", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("FX ingestor stopped")
|
||||||
|
}
|
||||||
32
api/fx/oracle/.air.toml
Normal file
32
api/fx/oracle/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Config file for Air in TOML format
|
||||||
|
|
||||||
|
root = "./../.."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildDate=$(date)'\""
|
||||||
|
bin = "./app"
|
||||||
|
full_bin = "./app --debug --config.file=config.yml"
|
||||||
|
include_ext = ["go", "yaml", "yml"]
|
||||||
|
exclude_dir = ["fx/oracle/tmp", "pkg/.git", "fx/oracle/env"]
|
||||||
|
exclude_regex = ["_test\\.go"]
|
||||||
|
exclude_unchanged = true
|
||||||
|
follow_symlink = true
|
||||||
|
log = "air.log"
|
||||||
|
delay = 0
|
||||||
|
stop_on_error = true
|
||||||
|
send_interrupt = true
|
||||||
|
kill_delay = 500
|
||||||
|
args_bin = []
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
main = "magenta"
|
||||||
|
watcher = "cyan"
|
||||||
|
build = "yellow"
|
||||||
|
runner = "green"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
3
api/fx/oracle/.gitignore
vendored
Normal file
3
api/fx/oracle/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
252
api/fx/oracle/client/client.go
Normal file
252
api/fx/oracle/client/client.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client exposes typed helpers around the oracle gRPC API.
|
||||||
|
type Client interface {
|
||||||
|
LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
||||||
|
GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestMeta carries optional multi-tenant context for oracle calls.
|
||||||
|
type RequestMeta struct {
|
||||||
|
TenantRef string
|
||||||
|
OrganizationRef string
|
||||||
|
Trace *tracev1.TraceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
type LatestRateParams struct {
|
||||||
|
Meta RequestMeta
|
||||||
|
Pair *fxv1.CurrencyPair
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateSnapshot struct {
|
||||||
|
Pair *fxv1.CurrencyPair
|
||||||
|
Mid string
|
||||||
|
Bid string
|
||||||
|
Ask string
|
||||||
|
SpreadBps string
|
||||||
|
Provider string
|
||||||
|
RateRef string
|
||||||
|
AsOf time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetQuoteParams struct {
|
||||||
|
Meta RequestMeta
|
||||||
|
Pair *fxv1.CurrencyPair
|
||||||
|
Side fxv1.Side
|
||||||
|
BaseAmount *moneyv1.Money
|
||||||
|
QuoteAmount *moneyv1.Money
|
||||||
|
Firm bool
|
||||||
|
TTL time.Duration
|
||||||
|
PreferredProvider string
|
||||||
|
MaxAge time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quote struct {
|
||||||
|
QuoteRef string
|
||||||
|
Pair *fxv1.CurrencyPair
|
||||||
|
Side fxv1.Side
|
||||||
|
Price string
|
||||||
|
BaseAmount *moneyv1.Money
|
||||||
|
QuoteAmount *moneyv1.Money
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Provider string
|
||||||
|
RateRef string
|
||||||
|
Firm bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type grpcOracleClient interface {
|
||||||
|
GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, opts ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error)
|
||||||
|
LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, opts ...grpc.CallOption) (*oraclev1.LatestRateResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type oracleClient struct {
|
||||||
|
cfg Config
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
client grpcOracleClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// New dials the oracle endpoint and returns a ready client.
|
||||||
|
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
||||||
|
cfg.setDefaults()
|
||||||
|
if strings.TrimSpace(cfg.Address) == "" {
|
||||||
|
return nil, errors.New("oracle: address is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||||
|
dialOpts = append(dialOpts, opts...)
|
||||||
|
|
||||||
|
if cfg.Insecure {
|
||||||
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
} else {
|
||||||
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oracle: dial %s: %w", cfg.Address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oracleClient{
|
||||||
|
cfg: cfg,
|
||||||
|
conn: conn,
|
||||||
|
client: oraclev1.NewOracleClient(conn),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithClient injects a pre-built oracle client (useful for tests).
|
||||||
|
func NewWithClient(cfg Config, oc grpcOracleClient) Client {
|
||||||
|
cfg.setDefaults()
|
||||||
|
return &oracleClient{
|
||||||
|
cfg: cfg,
|
||||||
|
client: oc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oracleClient) Close() error {
|
||||||
|
if c.conn != nil {
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oracleClient) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
||||||
|
if req.Pair == nil {
|
||||||
|
return nil, errors.New("oracle: pair is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := c.client.LatestRate(callCtx, &oraclev1.LatestRateRequest{
|
||||||
|
Meta: toProtoMeta(req.Meta),
|
||||||
|
Pair: req.Pair,
|
||||||
|
Provider: req.Provider,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oracle: latest rate: %w", err)
|
||||||
|
}
|
||||||
|
if resp.GetRate() == nil {
|
||||||
|
return nil, errors.New("oracle: latest rate: empty payload")
|
||||||
|
}
|
||||||
|
return fromProtoRate(resp.GetRate()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oracleClient) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
||||||
|
if req.Pair == nil {
|
||||||
|
return nil, errors.New("oracle: pair is required")
|
||||||
|
}
|
||||||
|
if req.Side == fxv1.Side_SIDE_UNSPECIFIED {
|
||||||
|
return nil, errors.New("oracle: side is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSupplied := req.BaseAmount != nil
|
||||||
|
quoteSupplied := req.QuoteAmount != nil
|
||||||
|
if baseSupplied == quoteSupplied {
|
||||||
|
return nil, errors.New("oracle: exactly one of base_amount or quote_amount must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
protoReq := &oraclev1.GetQuoteRequest{
|
||||||
|
Meta: toProtoMeta(req.Meta),
|
||||||
|
Pair: req.Pair,
|
||||||
|
Side: req.Side,
|
||||||
|
Firm: req.Firm,
|
||||||
|
PreferredProvider: req.PreferredProvider,
|
||||||
|
}
|
||||||
|
if req.TTL > 0 {
|
||||||
|
protoReq.TtlMs = req.TTL.Milliseconds()
|
||||||
|
}
|
||||||
|
if req.MaxAge > 0 {
|
||||||
|
protoReq.MaxAgeMs = int32(req.MaxAge.Milliseconds())
|
||||||
|
}
|
||||||
|
if baseSupplied {
|
||||||
|
protoReq.AmountInput = &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: req.BaseAmount}
|
||||||
|
} else {
|
||||||
|
protoReq.AmountInput = &oraclev1.GetQuoteRequest_QuoteAmount{QuoteAmount: req.QuoteAmount}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.GetQuote(callCtx, protoReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oracle: get quote: %w", err)
|
||||||
|
}
|
||||||
|
if resp.GetQuote() == nil {
|
||||||
|
return nil, errors.New("oracle: get quote: empty payload")
|
||||||
|
}
|
||||||
|
return fromProtoQuote(resp.GetQuote()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oracleClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
if _, ok := ctx.Deadline(); ok {
|
||||||
|
return context.WithCancel(ctx)
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, c.cfg.CallTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoMeta(meta RequestMeta) *oraclev1.RequestMeta {
|
||||||
|
if meta.TenantRef == "" && meta.OrganizationRef == "" && meta.Trace == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &oraclev1.RequestMeta{
|
||||||
|
TenantRef: meta.TenantRef,
|
||||||
|
OrganizationRef: meta.OrganizationRef,
|
||||||
|
Trace: meta.Trace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromProtoRate(rate *oraclev1.RateSnapshot) *RateSnapshot {
|
||||||
|
if rate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RateSnapshot{
|
||||||
|
Pair: rate.Pair,
|
||||||
|
Mid: rate.GetMid().GetValue(),
|
||||||
|
Bid: rate.GetBid().GetValue(),
|
||||||
|
Ask: rate.GetAsk().GetValue(),
|
||||||
|
SpreadBps: rate.GetSpreadBps().GetValue(),
|
||||||
|
Provider: rate.GetProvider(),
|
||||||
|
RateRef: rate.GetRateRef(),
|
||||||
|
AsOf: time.UnixMilli(rate.GetAsofUnixMs()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromProtoQuote(quote *oraclev1.Quote) *Quote {
|
||||||
|
if quote == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Quote{
|
||||||
|
QuoteRef: quote.GetQuoteRef(),
|
||||||
|
Pair: quote.Pair,
|
||||||
|
Side: quote.GetSide(),
|
||||||
|
Price: quote.GetPrice().GetValue(),
|
||||||
|
BaseAmount: quote.BaseAmount,
|
||||||
|
QuoteAmount: quote.QuoteAmount,
|
||||||
|
ExpiresAt: time.UnixMilli(quote.GetExpiresAtUnixMs()),
|
||||||
|
Provider: quote.GetProvider(),
|
||||||
|
RateRef: quote.GetRateRef(),
|
||||||
|
Firm: quote.GetFirm(),
|
||||||
|
}
|
||||||
|
}
|
||||||
116
api/fx/oracle/client/client_test.go
Normal file
116
api/fx/oracle/client/client_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubOracle struct {
|
||||||
|
latestResp *oraclev1.LatestRateResponse
|
||||||
|
latestErr error
|
||||||
|
|
||||||
|
quoteResp *oraclev1.GetQuoteResponse
|
||||||
|
quoteErr error
|
||||||
|
|
||||||
|
lastLatest *oraclev1.LatestRateRequest
|
||||||
|
lastQuote *oraclev1.GetQuoteRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubOracle) LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, _ ...grpc.CallOption) (*oraclev1.LatestRateResponse, error) {
|
||||||
|
s.lastLatest = in
|
||||||
|
return s.latestResp, s.latestErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubOracle) GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, _ ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error) {
|
||||||
|
s.lastQuote = in
|
||||||
|
return s.quoteResp, s.quoteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestRate(t *testing.T) {
|
||||||
|
expectedTime := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
|
||||||
|
stub := &stubOracle{
|
||||||
|
latestResp: &oraclev1.LatestRateResponse{
|
||||||
|
Rate: &oraclev1.RateSnapshot{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
|
Mid: &moneyv1.Decimal{Value: "1.1000"},
|
||||||
|
Bid: &moneyv1.Decimal{Value: "1.0995"},
|
||||||
|
Ask: &moneyv1.Decimal{Value: "1.1005"},
|
||||||
|
SpreadBps: &moneyv1.Decimal{Value: "5"},
|
||||||
|
Provider: "ECB",
|
||||||
|
RateRef: "ECB-20240101",
|
||||||
|
AsofUnixMs: expectedTime.UnixMilli(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewWithClient(Config{}, stub)
|
||||||
|
resp, err := client.LatestRate(context.Background(), LatestRateParams{
|
||||||
|
Meta: RequestMeta{
|
||||||
|
TenantRef: "tenant",
|
||||||
|
OrganizationRef: "org",
|
||||||
|
},
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
|
Provider: "ECB",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestRate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stub.lastLatest.GetProvider() != "ECB" {
|
||||||
|
t.Fatalf("expected provider to propagate, got %s", stub.lastLatest.GetProvider())
|
||||||
|
}
|
||||||
|
if resp.Provider != "ECB" || resp.RateRef != "ECB-20240101" {
|
||||||
|
t.Fatalf("unexpected response: %+v", resp)
|
||||||
|
}
|
||||||
|
if !resp.AsOf.Equal(expectedTime) {
|
||||||
|
t.Fatalf("expected as-of %s, got %s", expectedTime, resp.AsOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetQuote(t *testing.T) {
|
||||||
|
expiresAt := time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC)
|
||||||
|
stub := &stubOracle{
|
||||||
|
quoteResp: &oraclev1.GetQuoteResponse{
|
||||||
|
Quote: &oraclev1.Quote{
|
||||||
|
QuoteRef: "quote-123",
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
Price: &moneyv1.Decimal{Value: "1.2500"},
|
||||||
|
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
|
||||||
|
QuoteAmount: &moneyv1.Money{Amount: "125.00", Currency: "USD"},
|
||||||
|
ExpiresAtUnixMs: expiresAt.UnixMilli(),
|
||||||
|
Provider: "Test",
|
||||||
|
RateRef: "test-ref",
|
||||||
|
Firm: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewWithClient(Config{}, stub)
|
||||||
|
resp, err := client.GetQuote(context.Background(), GetQuoteParams{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
|
||||||
|
Firm: true,
|
||||||
|
TTL: 2 * time.Second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetQuote returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stub.lastQuote.GetFirm() != true {
|
||||||
|
t.Fatalf("expected firm flag to propagate")
|
||||||
|
}
|
||||||
|
if stub.lastQuote.GetTtlMs() == 0 {
|
||||||
|
t.Fatalf("expected ttl to be populated")
|
||||||
|
}
|
||||||
|
if resp.QuoteRef != "quote-123" || resp.Price != "1.2500" || !resp.ExpiresAt.Equal(expiresAt) {
|
||||||
|
t.Fatalf("unexpected quote response: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
api/fx/oracle/client/config.go
Normal file
20
api/fx/oracle/client/config.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Config captures connection settings for the FX oracle gRPC service.
|
||||||
|
type Config struct {
|
||||||
|
Address string
|
||||||
|
DialTimeout time.Duration
|
||||||
|
CallTimeout time.Duration
|
||||||
|
Insecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) setDefaults() {
|
||||||
|
if c.DialTimeout <= 0 {
|
||||||
|
c.DialTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if c.CallTimeout <= 0 {
|
||||||
|
c.CallTimeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
60
api/fx/oracle/client/fake.go
Normal file
60
api/fx/oracle/client/fake.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fake implements Client for tests.
|
||||||
|
type Fake struct {
|
||||||
|
LatestRateFn func(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
||||||
|
GetQuoteFn func(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
||||||
|
CloseFn func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
||||||
|
if f.LatestRateFn != nil {
|
||||||
|
return f.LatestRateFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &RateSnapshot{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
|
Mid: "1.1000",
|
||||||
|
Bid: "1.0995",
|
||||||
|
Ask: "1.1005",
|
||||||
|
SpreadBps: "5",
|
||||||
|
Provider: "fake",
|
||||||
|
RateRef: "fake",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
||||||
|
if f.GetQuoteFn != nil {
|
||||||
|
return f.GetQuoteFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &Quote{
|
||||||
|
QuoteRef: "fake-quote",
|
||||||
|
Pair: req.Pair,
|
||||||
|
Side: req.Side,
|
||||||
|
Price: "1.1000",
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Amount: "100.00",
|
||||||
|
Currency: req.Pair.GetBase(),
|
||||||
|
},
|
||||||
|
QuoteAmount: &moneyv1.Money{
|
||||||
|
Amount: "110.00",
|
||||||
|
Currency: req.Pair.GetQuote(),
|
||||||
|
},
|
||||||
|
Provider: "fake",
|
||||||
|
RateRef: "fake",
|
||||||
|
Firm: req.Firm,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Close() error {
|
||||||
|
if f.CloseFn != nil {
|
||||||
|
return f.CloseFn()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
34
api/fx/oracle/config.yml
Normal file
34
api/fx/oracle/config.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50051"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9400"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: FX_MONGO_HOST
|
||||||
|
port_env: FX_MONGO_PORT
|
||||||
|
database_env: FX_MONGO_DATABASE
|
||||||
|
user_env: FX_MONGO_USER
|
||||||
|
password_env: FX_MONGO_PASSWORD
|
||||||
|
auth_source_env: FX_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: FX_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
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 Oracle
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
1
api/fx/oracle/env/.gitignore
vendored
Normal file
1
api/fx/oracle/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env.api
|
||||||
54
api/fx/oracle/go.mod
Normal file
54
api/fx/oracle/go.mod
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
module github.com/tech/sendico/fx/oracle
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/fx/storage => ../storage
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/tech/sendico/fx/storage v0.0.0
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
google.golang.org/grpc v1.76.0
|
||||||
|
google.golang.org/protobuf v1.36.10
|
||||||
|
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.132.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/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.1 // 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.47.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.11 // 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.2 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||||
|
)
|
||||||
225
api/fx/oracle/go.sum
Normal file
225
api/fx/oracle/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.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||||
|
github.com/casbin/casbin/v2 v2.132.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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
|
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.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||||
|
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||||
|
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||||
|
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.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||||
|
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||||
|
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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.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.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
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-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/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/fx/oracle/internal/appversion/version.go
Normal file
27
api/fx/oracle/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: "MeetX Connectica FX Oracle Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&vi)
|
||||||
|
}
|
||||||
101
api/fx/oracle/internal/server/internal/serverimp.go
Normal file
101
api/fx/oracle/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/oracle/internal/service/oracle"
|
||||||
|
"github.com/tech/sendico/fx/storage"
|
||||||
|
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *grpcapp.Config
|
||||||
|
app *grpcapp.App[storage.Repository]
|
||||||
|
}
|
||||||
|
|
||||||
|
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) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
|
return mongostorage.New(logger, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
|
return oracle.NewService(logger, repo, producer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.app = app
|
||||||
|
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) loadConfig() (*grpcapp.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 := &grpcapp.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.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50051",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
11
api/fx/oracle/internal/server/server.go
Normal file
11
api/fx/oracle/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/fx/oracle/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)
|
||||||
|
}
|
||||||
223
api/fx/oracle/internal/service/oracle/calculator.go
Normal file
223
api/fx/oracle/internal/service/oracle/calculator.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type quoteComputation struct {
|
||||||
|
pair *model.Pair
|
||||||
|
rate *model.RateSnapshot
|
||||||
|
sideProto fxv1.Side
|
||||||
|
sideModel model.QuoteSide
|
||||||
|
price *big.Rat
|
||||||
|
baseInput *big.Rat
|
||||||
|
quoteInput *big.Rat
|
||||||
|
amountType model.QuoteAmountType
|
||||||
|
baseRounded *big.Rat
|
||||||
|
quoteRounded *big.Rat
|
||||||
|
priceRounded *big.Rat
|
||||||
|
baseScale uint32
|
||||||
|
quoteScale uint32
|
||||||
|
priceScale uint32
|
||||||
|
provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQuoteComputation(pair *model.Pair, rate *model.RateSnapshot, side fxv1.Side, provider string) (*quoteComputation, error) {
|
||||||
|
if pair == nil || rate == nil {
|
||||||
|
return nil, merrors.InvalidArgument("oracle: missing pair or rate")
|
||||||
|
}
|
||||||
|
sideModel := protoSideToModel(side)
|
||||||
|
if sideModel == "" {
|
||||||
|
return nil, merrors.InvalidArgument("oracle: unsupported side")
|
||||||
|
}
|
||||||
|
price, err := priceFromRate(rate, side)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(provider) == "" {
|
||||||
|
provider = rate.Provider
|
||||||
|
}
|
||||||
|
return "eComputation{
|
||||||
|
pair: pair,
|
||||||
|
rate: rate,
|
||||||
|
sideProto: side,
|
||||||
|
sideModel: sideModel,
|
||||||
|
price: price,
|
||||||
|
baseScale: pair.BaseMeta.Decimals,
|
||||||
|
quoteScale: pair.QuoteMeta.Decimals,
|
||||||
|
priceScale: pair.QuoteMeta.Decimals,
|
||||||
|
provider: provider,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qc *quoteComputation) withBaseInput(m *moneyv1.Money) error {
|
||||||
|
if m == nil {
|
||||||
|
return merrors.InvalidArgument("oracle: base amount missing")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Base) {
|
||||||
|
return merrors.InvalidArgument("oracle: base amount currency mismatch")
|
||||||
|
}
|
||||||
|
val, err := ratFromString(m.GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qc.baseInput = val
|
||||||
|
qc.amountType = model.QuoteAmountTypeBase
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qc *quoteComputation) withQuoteInput(m *moneyv1.Money) error {
|
||||||
|
if m == nil {
|
||||||
|
return merrors.InvalidArgument("oracle: quote amount missing")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Quote) {
|
||||||
|
return merrors.InvalidArgument("oracle: quote amount currency mismatch")
|
||||||
|
}
|
||||||
|
val, err := ratFromString(m.GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qc.quoteInput = val
|
||||||
|
qc.amountType = model.QuoteAmountTypeQuote
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qc *quoteComputation) compute() error {
|
||||||
|
var baseRaw, quoteRaw *big.Rat
|
||||||
|
switch qc.amountType {
|
||||||
|
case model.QuoteAmountTypeBase:
|
||||||
|
baseRaw = qc.baseInput
|
||||||
|
quoteRaw = mulRat(qc.baseInput, qc.price)
|
||||||
|
case model.QuoteAmountTypeQuote:
|
||||||
|
quoteRaw = qc.quoteInput
|
||||||
|
base, err := divRat(qc.quoteInput, qc.price)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
baseRaw = base
|
||||||
|
default:
|
||||||
|
return merrors.InvalidArgument("oracle: amount type not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
qc.baseRounded, err = roundRatToScale(baseRaw, qc.baseScale, qc.pair.BaseMeta.Rounding)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qc.quoteRounded, err = roundRatToScale(quoteRaw, qc.quoteScale, qc.pair.QuoteMeta.Rounding)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qc.priceRounded, err = roundRatToScale(qc.price, qc.priceScale, qc.pair.QuoteMeta.Rounding)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *oraclev1.GetQuoteRequest) (*model.Quote, error) {
|
||||||
|
if qc.baseRounded == nil || qc.quoteRounded == nil || qc.priceRounded == nil {
|
||||||
|
return nil, merrors.Internal("oracle: computation not executed")
|
||||||
|
}
|
||||||
|
|
||||||
|
quote := &model.Quote{
|
||||||
|
QuoteRef: uuid.NewString(),
|
||||||
|
Firm: firm,
|
||||||
|
Status: model.QuoteStatusIssued,
|
||||||
|
Pair: qc.pair.Pair,
|
||||||
|
Side: qc.sideModel,
|
||||||
|
Price: formatRat(qc.priceRounded, qc.priceScale),
|
||||||
|
BaseAmount: model.Money{
|
||||||
|
Currency: qc.pair.Pair.Base,
|
||||||
|
Amount: formatRat(qc.baseRounded, qc.baseScale),
|
||||||
|
},
|
||||||
|
QuoteAmount: model.Money{
|
||||||
|
Currency: qc.pair.Pair.Quote,
|
||||||
|
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
|
||||||
|
},
|
||||||
|
AmountType: qc.amountType,
|
||||||
|
RateRef: qc.rate.RateRef,
|
||||||
|
Provider: qc.provider,
|
||||||
|
PreferredProvider: req.GetPreferredProvider(),
|
||||||
|
RequestedTTLMs: req.GetTtlMs(),
|
||||||
|
MaxAgeToleranceMs: int64(req.GetMaxAgeMs()),
|
||||||
|
Meta: buildQuoteMeta(req.GetMeta()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if firm {
|
||||||
|
quote.ExpiresAtUnixMs = expiryMillis
|
||||||
|
expiry := time.UnixMilli(expiryMillis)
|
||||||
|
quote.ExpiresAt = &expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
return quote, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trace := meta.GetTrace()
|
||||||
|
qm := &model.QuoteMeta{
|
||||||
|
RequestRef: deriveRequestRef(meta, trace),
|
||||||
|
TenantRef: meta.GetTenantRef(),
|
||||||
|
TraceRef: deriveTraceRef(meta, trace),
|
||||||
|
IdempotencyKey: deriveIdempotencyKey(meta, trace),
|
||||||
|
}
|
||||||
|
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||||
|
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
|
||||||
|
qm.SetOrganizationRef(objID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return qm
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoSideToModel(side fxv1.Side) model.QuoteSide {
|
||||||
|
switch side {
|
||||||
|
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||||
|
return model.QuoteSideBuyBaseSellQuote
|
||||||
|
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||||
|
return model.QuoteSideSellBaseBuyQuote
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
|
||||||
|
if ttlMs <= 0 {
|
||||||
|
return 0, merrors.InvalidArgument("oracle: ttl must be positive")
|
||||||
|
}
|
||||||
|
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||||
|
if trace != nil && trace.GetRequestRef() != "" {
|
||||||
|
return trace.GetRequestRef()
|
||||||
|
}
|
||||||
|
return meta.GetRequestRef()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||||
|
if trace != nil && trace.GetTraceRef() != "" {
|
||||||
|
return trace.GetTraceRef()
|
||||||
|
}
|
||||||
|
return meta.GetTraceRef()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||||
|
if trace != nil && trace.GetIdempotencyKey() != "" {
|
||||||
|
return trace.GetIdempotencyKey()
|
||||||
|
}
|
||||||
|
return meta.GetIdempotencyKey()
|
||||||
|
}
|
||||||
221
api/fx/oracle/internal/service/oracle/cross.go
Normal file
221
api/fx/oracle/internal/service/oracle/cross.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type priceSet struct {
|
||||||
|
bid *big.Rat
|
||||||
|
ask *big.Rat
|
||||||
|
mid *big.Rat
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) computeCrossRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
if pair == nil || pair.Cross == nil || !pair.Cross.Enabled {
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.BaseLeg, provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
quoteSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.QuoteLeg, provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
basePrices, err := buildPriceSet(baseSnap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
quotePrices, err := buildPriceSet(quoteSnap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair.Cross.BaseLeg.Invert {
|
||||||
|
basePrices, err = invertPriceSet(basePrices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pair.Cross.QuoteLeg.Invert {
|
||||||
|
quotePrices, err = invertPriceSet(quotePrices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := multiplyPriceSets(basePrices, quotePrices)
|
||||||
|
if result.ask.Cmp(result.bid) < 0 {
|
||||||
|
result.ask, result.bid = result.bid, result.ask
|
||||||
|
}
|
||||||
|
|
||||||
|
spread := calcSpreadBps(result)
|
||||||
|
|
||||||
|
asOfMs := minNonZero(baseSnap.AsOfUnixMs, quoteSnap.AsOfUnixMs)
|
||||||
|
if asOfMs == 0 {
|
||||||
|
asOfMs = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
asOf := time.UnixMilli(asOfMs)
|
||||||
|
|
||||||
|
rateRef := fmt.Sprintf("cross|%s/%s|%s|%s+%s", pair.Pair.Base, pair.Pair.Quote, provider, baseSnap.RateRef, quoteSnap.RateRef)
|
||||||
|
|
||||||
|
return &model.RateSnapshot{
|
||||||
|
RateRef: rateRef,
|
||||||
|
Pair: pair.Pair,
|
||||||
|
Provider: provider,
|
||||||
|
Mid: formatPrice(result.mid),
|
||||||
|
Bid: formatPrice(result.bid),
|
||||||
|
Ask: formatPrice(result.ask),
|
||||||
|
SpreadBps: formatPrice(spread),
|
||||||
|
AsOfUnixMs: asOfMs,
|
||||||
|
AsOf: &asOf,
|
||||||
|
Source: "cross_rate",
|
||||||
|
ProviderRef: rateRef,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) fetchCrossLegSnapshot(ctx context.Context, leg model.CrossRateLeg, fallbackProvider string) (*model.RateSnapshot, error) {
|
||||||
|
provider := fallbackProvider
|
||||||
|
if strings.TrimSpace(leg.Provider) != "" {
|
||||||
|
provider = leg.Provider
|
||||||
|
}
|
||||||
|
if provider == "" {
|
||||||
|
return nil, merrors.InvalidArgument("oracle: cross leg provider missing")
|
||||||
|
}
|
||||||
|
return s.storage.Rates().LatestSnapshot(ctx, leg.Pair, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPriceSet(rate *model.RateSnapshot) (priceSet, error) {
|
||||||
|
if rate == nil {
|
||||||
|
return priceSet{}, merrors.InvalidArgument("oracle: cross rate requires underlying snapshot")
|
||||||
|
}
|
||||||
|
ask, err := parsePrice(rate.Ask)
|
||||||
|
if err != nil {
|
||||||
|
return priceSet{}, err
|
||||||
|
}
|
||||||
|
bid, err := parsePrice(rate.Bid)
|
||||||
|
if err != nil {
|
||||||
|
return priceSet{}, err
|
||||||
|
}
|
||||||
|
mid, err := parsePrice(rate.Mid)
|
||||||
|
if err != nil {
|
||||||
|
return priceSet{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ask == nil && bid == nil {
|
||||||
|
if mid == nil {
|
||||||
|
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing price data")
|
||||||
|
}
|
||||||
|
ask = new(big.Rat).Set(mid)
|
||||||
|
bid = new(big.Rat).Set(mid)
|
||||||
|
}
|
||||||
|
if ask == nil && mid != nil {
|
||||||
|
ask = new(big.Rat).Set(mid)
|
||||||
|
}
|
||||||
|
if bid == nil && mid != nil {
|
||||||
|
bid = new(big.Rat).Set(mid)
|
||||||
|
}
|
||||||
|
if ask == nil || bid == nil {
|
||||||
|
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing bid/ask data")
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := priceSet{
|
||||||
|
bid: new(big.Rat).Set(bid),
|
||||||
|
ask: new(big.Rat).Set(ask),
|
||||||
|
mid: averageOrMid(bid, ask, mid),
|
||||||
|
}
|
||||||
|
if ps.ask.Cmp(ps.bid) < 0 {
|
||||||
|
ps.ask, ps.bid = ps.bid, ps.ask
|
||||||
|
}
|
||||||
|
return ps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrice(value string) (*big.Rat, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return ratFromString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func averageOrMid(bid, ask, mid *big.Rat) *big.Rat {
|
||||||
|
if mid != nil {
|
||||||
|
return new(big.Rat).Set(mid)
|
||||||
|
}
|
||||||
|
sum := new(big.Rat).Add(bid, ask)
|
||||||
|
return sum.Quo(sum, big.NewRat(2, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func invertPriceSet(ps priceSet) (priceSet, error) {
|
||||||
|
if ps.ask.Sign() == 0 || ps.bid.Sign() == 0 {
|
||||||
|
return priceSet{}, merrors.InvalidArgument("oracle: cannot invert zero price")
|
||||||
|
}
|
||||||
|
one := big.NewRat(1, 1)
|
||||||
|
invBid := new(big.Rat).Quo(one, ps.ask)
|
||||||
|
invAsk := new(big.Rat).Quo(one, ps.bid)
|
||||||
|
var invMid *big.Rat
|
||||||
|
if ps.mid != nil && ps.mid.Sign() != 0 {
|
||||||
|
invMid = new(big.Rat).Quo(one, ps.mid)
|
||||||
|
} else {
|
||||||
|
invMid = averageOrMid(invBid, invAsk, nil)
|
||||||
|
}
|
||||||
|
result := priceSet{
|
||||||
|
bid: invBid,
|
||||||
|
ask: invAsk,
|
||||||
|
mid: invMid,
|
||||||
|
}
|
||||||
|
if result.ask.Cmp(result.bid) < 0 {
|
||||||
|
result.ask, result.bid = result.bid, result.ask
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiplyPriceSets(a, b priceSet) priceSet {
|
||||||
|
result := priceSet{
|
||||||
|
bid: mulRat(a.bid, b.bid),
|
||||||
|
ask: mulRat(a.ask, b.ask),
|
||||||
|
}
|
||||||
|
result.mid = averageOrMid(result.bid, result.ask, nil)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcSpreadBps(ps priceSet) *big.Rat {
|
||||||
|
if ps.mid == nil || ps.mid.Sign() == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spread := new(big.Rat).Sub(ps.ask, ps.bid)
|
||||||
|
if spread.Sign() < 0 {
|
||||||
|
spread.Neg(spread)
|
||||||
|
}
|
||||||
|
spread.Quo(spread, ps.mid)
|
||||||
|
spread.Mul(spread, big.NewRat(10000, 1))
|
||||||
|
return spread
|
||||||
|
}
|
||||||
|
|
||||||
|
func minNonZero(values ...int64) int64 {
|
||||||
|
var result int64
|
||||||
|
for _, v := range values {
|
||||||
|
if v <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result == 0 || v < result {
|
||||||
|
result = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPrice(r *big.Rat) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.FloatString(8)
|
||||||
|
}
|
||||||
67
api/fx/oracle/internal/service/oracle/math.go
Normal file
67
api/fx/oracle/internal/service/oracle/math.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convenience aliases to pkg/decimal for backward compatibility
|
||||||
|
var (
|
||||||
|
ratFromString = decimal.RatFromString
|
||||||
|
mulRat = decimal.MulRat
|
||||||
|
divRat = decimal.DivRat
|
||||||
|
formatRat = decimal.FormatRat
|
||||||
|
)
|
||||||
|
|
||||||
|
// roundRatToScale wraps pkg/decimal.RoundRatToScale with model RoundingMode conversion
|
||||||
|
func roundRatToScale(value *big.Rat, scale uint32, mode model.RoundingMode) (*big.Rat, error) {
|
||||||
|
return decimal.RoundRatToScale(value, scale, convertRoundingMode(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertRoundingMode converts fx/storage model.RoundingMode to pkg/decimal.RoundingMode
|
||||||
|
func convertRoundingMode(mode model.RoundingMode) decimal.RoundingMode {
|
||||||
|
switch mode {
|
||||||
|
case model.RoundingModeHalfEven:
|
||||||
|
return decimal.RoundingModeHalfEven
|
||||||
|
case model.RoundingModeHalfUp:
|
||||||
|
return decimal.RoundingModeHalfUp
|
||||||
|
case model.RoundingModeDown:
|
||||||
|
return decimal.RoundingModeDown
|
||||||
|
case model.RoundingModeUnspecified:
|
||||||
|
return decimal.RoundingModeUnspecified
|
||||||
|
default:
|
||||||
|
return decimal.RoundingModeHalfEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
|
||||||
|
var priceStr string
|
||||||
|
switch side {
|
||||||
|
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||||
|
priceStr = rate.Ask
|
||||||
|
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||||
|
priceStr = rate.Bid
|
||||||
|
default:
|
||||||
|
priceStr = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(priceStr) == "" {
|
||||||
|
priceStr = rate.Mid
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(priceStr) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("oracle: rate snapshot missing price")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratFromString(priceStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeFromUnixMilli(ms int64) time.Time {
|
||||||
|
return time.Unix(0, ms*int64(time.Millisecond))
|
||||||
|
}
|
||||||
65
api/fx/oracle/internal/service/oracle/metrics.go
Normal file
65
api/fx/oracle/internal/service/oracle/metrics.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
rpcRequestsTotal *prometheus.CounterVec
|
||||||
|
rpcLatency *prometheus.HistogramVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
rpcRequestsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "fx",
|
||||||
|
Subsystem: "oracle",
|
||||||
|
Name: "requests_total",
|
||||||
|
Help: "Total number of FX oracle RPC calls handled.",
|
||||||
|
},
|
||||||
|
[]string{"method", "result"},
|
||||||
|
)
|
||||||
|
|
||||||
|
rpcLatency = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "fx",
|
||||||
|
Subsystem: "oracle",
|
||||||
|
Name: "request_latency_seconds",
|
||||||
|
Help: "Latency of FX oracle RPC calls.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
},
|
||||||
|
[]string{"method", "result"},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeRPC(start time.Time, method string, err error) {
|
||||||
|
result := labelFromError(err)
|
||||||
|
rpcRequestsTotal.WithLabelValues(method, result).Inc()
|
||||||
|
rpcLatency.WithLabelValues(method, result).Observe(time.Since(start).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelFromError(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return strings.ToLower(codes.OK.String())
|
||||||
|
}
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
if !ok {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
code := st.Code()
|
||||||
|
if code == codes.OK {
|
||||||
|
return strings.ToLower(code.String())
|
||||||
|
}
|
||||||
|
return strings.ToLower(code.String())
|
||||||
|
}
|
||||||
402
api/fx/oracle/internal/service/oracle/service.go
Normal file
402
api/fx/oracle/internal/service/oracle/service.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/storage"
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceError string
|
||||||
|
|
||||||
|
func (e serviceError) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errSideRequired = serviceError("oracle: side is required")
|
||||||
|
errAmountsMutuallyExclusive = serviceError("oracle: exactly one amount must be provided")
|
||||||
|
errAmountRequired = serviceError("oracle: amount is required")
|
||||||
|
errQuoteRefRequired = serviceError("oracle: quote_ref is required")
|
||||||
|
errEmptyRequest = serviceError("oracle: request payload is empty")
|
||||||
|
errLedgerTxnRefRequired = serviceError("oracle: ledger_txn_ref is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
storage storage.Repository
|
||||||
|
producer pmessaging.Producer
|
||||||
|
oraclev1.UnimplementedOracleServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
||||||
|
initMetrics()
|
||||||
|
return &Service{
|
||||||
|
logger: logger.Named("oracle"),
|
||||||
|
storage: repo,
|
||||||
|
producer: prod,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
|
oraclev1.RegisterOracleServer(reg, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
|
responder := s.getQuoteResponder(ctx, req)
|
||||||
|
resp, err := responder(ctx)
|
||||||
|
observeRPC(start, "GetQuote", err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateQuote(ctx context.Context, req *oraclev1.ValidateQuoteRequest) (*oraclev1.ValidateQuoteResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
|
responder := s.validateQuoteResponder(ctx, req)
|
||||||
|
resp, err := responder(ctx)
|
||||||
|
observeRPC(start, "ValidateQuote", err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ConsumeQuote(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) (*oraclev1.ConsumeQuoteResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
|
responder := s.consumeQuoteResponder(ctx, req)
|
||||||
|
resp, err := responder(ctx)
|
||||||
|
observeRPC(start, "ConsumeQuote", err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LatestRate(ctx context.Context, req *oraclev1.LatestRateRequest) (*oraclev1.LatestRateResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
|
responder := s.latestRateResponder(ctx, req)
|
||||||
|
resp, err := responder(ctx)
|
||||||
|
observeRPC(start, "LatestRate", err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPairs(ctx context.Context, req *oraclev1.ListPairsRequest) (*oraclev1.ListPairsResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
|
responder := s.listPairsResponder(ctx, req)
|
||||||
|
resp, err := responder(ctx)
|
||||||
|
observeRPC(start, "ListPairs", err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteRequest) gsresponse.Responder[oraclev1.GetQuoteResponse] {
|
||||||
|
if req == nil {
|
||||||
|
req = &oraclev1.GetQuoteRequest{}
|
||||||
|
}
|
||||||
|
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
|
||||||
|
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
||||||
|
}
|
||||||
|
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
|
||||||
|
}
|
||||||
|
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
|
||||||
|
}
|
||||||
|
if err := s.pingStorage(ctx); err != nil {
|
||||||
|
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
pairMsg := req.GetPair()
|
||||||
|
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||||
|
}
|
||||||
|
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||||
|
|
||||||
|
pair, err := s.storage.Pairs().Get(ctx, pairKey)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := req.GetPreferredProvider()
|
||||||
|
if provider == "" {
|
||||||
|
provider = pair.DefaultProvider
|
||||||
|
}
|
||||||
|
if provider == "" && len(pair.Providers) > 0 {
|
||||||
|
provider = pair.Providers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
rate, err := s.getLatestRate(ctx, pair, provider)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||||
|
age := now.UnixMilli() - rate.AsOfUnixMs
|
||||||
|
if age > int64(maxAge) {
|
||||||
|
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||||
|
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GetBaseAmount() != nil {
|
||||||
|
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
} else if req.GetQuoteAmount() != nil {
|
||||||
|
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := comp.compute(); err != nil {
|
||||||
|
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := int64(0)
|
||||||
|
if req.GetFirm() {
|
||||||
|
expiry, err := computeExpiry(now, req.GetTtlMs())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
expiresAt = expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteModel, err := comp.buildModelQuote(req.GetFirm(), expiresAt, req)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GetFirm() {
|
||||||
|
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &oraclev1.GetQuoteResponse{
|
||||||
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
|
Quote: quoteModelToProto(quoteModel),
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.ValidateQuoteRequest) gsresponse.Responder[oraclev1.ValidateQuoteResponse] {
|
||||||
|
if req == nil {
|
||||||
|
req = &oraclev1.ValidateQuoteRequest{}
|
||||||
|
}
|
||||||
|
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
|
||||||
|
if req.GetQuoteRef() == "" {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||||
|
}
|
||||||
|
if err := s.pingStorage(ctx); err != nil {
|
||||||
|
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
resp := &oraclev1.ValidateQuoteResponse{
|
||||||
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
|
Quote: nil,
|
||||||
|
Valid: false,
|
||||||
|
Reason: "not_found",
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
valid := true
|
||||||
|
reason := ""
|
||||||
|
if quote.IsExpired(now) {
|
||||||
|
valid = false
|
||||||
|
reason = "expired"
|
||||||
|
} else if quote.Status == model.QuoteStatusConsumed {
|
||||||
|
valid = false
|
||||||
|
reason = "consumed"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &oraclev1.ValidateQuoteResponse{
|
||||||
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
|
Quote: quoteModelToProto(quote),
|
||||||
|
Valid: valid,
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) gsresponse.Responder[oraclev1.ConsumeQuoteResponse] {
|
||||||
|
if req == nil {
|
||||||
|
req = &oraclev1.ConsumeQuoteRequest{}
|
||||||
|
}
|
||||||
|
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||||
|
if req.GetQuoteRef() == "" {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||||
|
}
|
||||||
|
if req.GetLedgerTxnRef() == "" {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
|
||||||
|
}
|
||||||
|
if err := s.pingStorage(ctx); err != nil {
|
||||||
|
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, storage.ErrQuoteExpired):
|
||||||
|
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
||||||
|
case errors.Is(err, storage.ErrQuoteConsumed):
|
||||||
|
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
||||||
|
case errors.Is(err, storage.ErrQuoteNotFirm):
|
||||||
|
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &oraclev1.ConsumeQuoteResponse{
|
||||||
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
|
Consumed: true,
|
||||||
|
Reason: "consumed",
|
||||||
|
}
|
||||||
|
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestRateRequest) gsresponse.Responder[oraclev1.LatestRateResponse] {
|
||||||
|
if req == nil {
|
||||||
|
req = &oraclev1.LatestRateRequest{}
|
||||||
|
}
|
||||||
|
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
|
||||||
|
if err := s.pingStorage(ctx); err != nil {
|
||||||
|
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
pairMsg := req.GetPair()
|
||||||
|
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||||
|
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||||
|
}
|
||||||
|
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||||
|
|
||||||
|
pairMeta, err := s.storage.Pairs().Get(ctx, pair)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := req.GetProvider()
|
||||||
|
if provider == "" {
|
||||||
|
provider = pairMeta.DefaultProvider
|
||||||
|
}
|
||||||
|
if provider == "" && len(pairMeta.Providers) > 0 {
|
||||||
|
provider = pairMeta.Providers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
rate, err := s.getLatestRate(ctx, pairMeta, provider)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
default:
|
||||||
|
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &oraclev1.LatestRateResponse{
|
||||||
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
|
Rate: rateModelToProto(rate),
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPairsRequest) gsresponse.Responder[oraclev1.ListPairsResponse] {
|
||||||
|
if req == nil {
|
||||||
|
req = &oraclev1.ListPairsRequest{}
|
||||||
|
}
|
||||||
|
s.logger.Debug("Handling ListPairs")
|
||||||
|
if err := s.pingStorage(ctx); err != nil {
|
||||||
|
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
pairs, err := s.storage.Pairs().ListEnabled(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||||
|
}
|
||||||
|
result := make([]*oraclev1.PairMeta, 0, len(pairs))
|
||||||
|
for _, pair := range pairs {
|
||||||
|
result = append(result, pairModelToProto(pair))
|
||||||
|
}
|
||||||
|
resp := &oraclev1.ListPairsResponse{
|
||||||
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
|
Pairs: result,
|
||||||
|
}
|
||||||
|
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pingStorage(ctx context.Context) error {
|
||||||
|
if s.storage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.storage.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getLatestRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
rate, err := s.storage.Rates().LatestSnapshot(ctx, pair.Pair, provider)
|
||||||
|
if err == nil {
|
||||||
|
return rate, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
crossRate, crossErr := s.computeCrossRate(ctx, pair, provider)
|
||||||
|
if crossErr != nil {
|
||||||
|
if errors.Is(crossErr, merrors.ErrNoData) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, crossErr
|
||||||
|
}
|
||||||
|
s.logger.Debug("Derived cross rate", zap.String("pair", pair.Pair.Base+"/"+pair.Pair.Quote), zap.String("provider", provider))
|
||||||
|
return crossRate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ oraclev1.OracleServer = (*Service)(nil)
|
||||||
467
api/fx/oracle/internal/service/oracle/service_test.go
Normal file
467
api/fx/oracle/internal/service/oracle/service_test.go
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/storage"
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repositoryStub struct {
|
||||||
|
rates storage.RatesStore
|
||||||
|
quotes storage.QuotesStore
|
||||||
|
pairs storage.PairStore
|
||||||
|
currencies storage.CurrencyStore
|
||||||
|
pingErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryStub) Ping(ctx context.Context) error { return r.pingErr }
|
||||||
|
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
|
||||||
|
func (r *repositoryStub) Quotes() storage.QuotesStore { return r.quotes }
|
||||||
|
func (r *repositoryStub) Pairs() storage.PairStore { return r.pairs }
|
||||||
|
func (r *repositoryStub) Currencies() storage.CurrencyStore {
|
||||||
|
return r.currencies
|
||||||
|
}
|
||||||
|
|
||||||
|
type ratesStoreStub struct {
|
||||||
|
latestFn func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ratesStoreStub) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ratesStoreStub) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
if r.latestFn != nil {
|
||||||
|
return r.latestFn(ctx, pair, provider)
|
||||||
|
}
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
|
||||||
|
type quotesStoreStub struct {
|
||||||
|
issueFn func(ctx context.Context, quote *model.Quote) error
|
||||||
|
getFn func(ctx context.Context, ref string) (*model.Quote, error)
|
||||||
|
consumeFn func(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quotesStoreStub) Issue(ctx context.Context, quote *model.Quote) error {
|
||||||
|
if q.issueFn != nil {
|
||||||
|
return q.issueFn(ctx, quote)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quotesStoreStub) GetByRef(ctx context.Context, ref string) (*model.Quote, error) {
|
||||||
|
if q.getFn != nil {
|
||||||
|
return q.getFn(ctx, ref)
|
||||||
|
}
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quotesStoreStub) Consume(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error) {
|
||||||
|
if q.consumeFn != nil {
|
||||||
|
return q.consumeFn(ctx, ref, ledger, when)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quotesStoreStub) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type pairStoreStub struct {
|
||||||
|
getFn func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
|
||||||
|
listFn func(ctx context.Context) ([]*model.Pair, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pairStoreStub) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
||||||
|
if p.listFn != nil {
|
||||||
|
return p.listFn(ctx)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pairStoreStub) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
|
if p.getFn != nil {
|
||||||
|
return p.getFn(ctx, pair)
|
||||||
|
}
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pairStoreStub) Upsert(ctx context.Context, pair *model.Pair) error { return nil }
|
||||||
|
|
||||||
|
type currencyStoreStub struct{}
|
||||||
|
|
||||||
|
func (currencyStoreStub) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
func (currencyStoreStub) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (currencyStoreStub) Upsert(ctx context.Context, currency *model.Currency) error { return nil }
|
||||||
|
|
||||||
|
func TestServiceGetQuoteFirm(t *testing.T) {
|
||||||
|
repo := &repositoryStub{}
|
||||||
|
repo.pairs = &pairStoreStub{
|
||||||
|
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
|
return &model.Pair{
|
||||||
|
Pair: pair,
|
||||||
|
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo.rates = &ratesStoreStub{
|
||||||
|
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
return &model.RateSnapshot{
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
Ask: "1.10",
|
||||||
|
Bid: "1.08",
|
||||||
|
RateRef: "rate#1",
|
||||||
|
AsOfUnixMs: time.Now().UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
savedQuote := &model.Quote{}
|
||||||
|
repo.quotes = "esStoreStub{
|
||||||
|
issueFn: func(ctx context.Context, quote *model.Quote) error {
|
||||||
|
*savedQuote = *quote
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo.currencies = currencyStoreStub{}
|
||||||
|
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
req := &oraclev1.GetQuoteRequest{
|
||||||
|
Meta: &oraclev1.RequestMeta{
|
||||||
|
TenantRef: "tenant",
|
||||||
|
Trace: &tracev1.TraceContext{RequestRef: "req"},
|
||||||
|
},
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{
|
||||||
|
Currency: "USD",
|
||||||
|
Amount: "100",
|
||||||
|
}},
|
||||||
|
Firm: true,
|
||||||
|
TtlMs: 60000,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.GetQuote(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.GetQuote().GetFirm() != true {
|
||||||
|
t.Fatalf("expected firm quote")
|
||||||
|
}
|
||||||
|
if resp.GetQuote().GetQuoteAmount().GetAmount() != "110.00" {
|
||||||
|
t.Fatalf("unexpected quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
|
||||||
|
}
|
||||||
|
if savedQuote.QuoteRef == "" {
|
||||||
|
t.Fatalf("expected quote persisted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceGetQuoteRateNotFound(t *testing.T) {
|
||||||
|
repo := &repositoryStub{
|
||||||
|
pairs: &pairStoreStub{
|
||||||
|
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
|
return &model.Pair{
|
||||||
|
Pair: pair,
|
||||||
|
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rates: &ratesStoreStub{latestFn: func(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
_, err := svc.GetQuote(context.Background(), &oraclev1.GetQuoteRequest{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceGetQuoteCrossRate(t *testing.T) {
|
||||||
|
repo := &repositoryStub{}
|
||||||
|
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
|
||||||
|
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
|
||||||
|
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
|
||||||
|
|
||||||
|
repo.pairs = &pairStoreStub{
|
||||||
|
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
|
if pair != targetPair {
|
||||||
|
t.Fatalf("unexpected pair lookup: %v", pair)
|
||||||
|
}
|
||||||
|
return &model.Pair{
|
||||||
|
Pair: pair,
|
||||||
|
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
DefaultProvider: "CROSSPROV",
|
||||||
|
Cross: &model.CrossRateConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BaseLeg: model.CrossRateLeg{
|
||||||
|
Pair: baseLegPair,
|
||||||
|
Invert: true,
|
||||||
|
},
|
||||||
|
QuoteLeg: model.CrossRateLeg{
|
||||||
|
Pair: quoteLegPair,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo.rates = &ratesStoreStub{
|
||||||
|
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
switch pair {
|
||||||
|
case targetPair:
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
case baseLegPair:
|
||||||
|
return &model.RateSnapshot{
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
Ask: "0.90",
|
||||||
|
Bid: "0.90",
|
||||||
|
Mid: "0.90",
|
||||||
|
RateRef: "base-leg",
|
||||||
|
AsOfUnixMs: 1_000,
|
||||||
|
}, nil
|
||||||
|
case quoteLegPair:
|
||||||
|
return &model.RateSnapshot{
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
Ask: "90",
|
||||||
|
Bid: "90",
|
||||||
|
Mid: "90",
|
||||||
|
RateRef: "quote-leg",
|
||||||
|
AsOfUnixMs: 2_000,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo.quotes = "esStoreStub{}
|
||||||
|
repo.currencies = currencyStoreStub{}
|
||||||
|
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
req := &oraclev1.GetQuoteRequest{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "EUR", Amount: "1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.GetQuote(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.GetQuote().GetPrice().GetValue() != "100.00" {
|
||||||
|
t.Fatalf("unexpected cross price: %s", resp.GetQuote().GetPrice().GetValue())
|
||||||
|
}
|
||||||
|
if resp.GetQuote().GetQuoteAmount().GetAmount() != "100.00" {
|
||||||
|
t.Fatalf("unexpected cross quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(resp.GetQuote().GetRateRef(), "cross|") {
|
||||||
|
t.Fatalf("expected cross rate ref, got %s", resp.GetQuote().GetRateRef())
|
||||||
|
}
|
||||||
|
if resp.GetQuote().GetProvider() != "CROSSPROV" {
|
||||||
|
t.Fatalf("unexpected provider: %s", resp.GetQuote().GetProvider())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestRateCross(t *testing.T) {
|
||||||
|
repo := &repositoryStub{}
|
||||||
|
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
|
||||||
|
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
|
||||||
|
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
|
||||||
|
|
||||||
|
repo.pairs = &pairStoreStub{
|
||||||
|
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
|
if pair != targetPair {
|
||||||
|
t.Fatalf("unexpected pair lookup: %v", pair)
|
||||||
|
}
|
||||||
|
return &model.Pair{
|
||||||
|
Pair: pair,
|
||||||
|
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
DefaultProvider: "CROSSPROV",
|
||||||
|
Cross: &model.CrossRateConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BaseLeg: model.CrossRateLeg{
|
||||||
|
Pair: baseLegPair,
|
||||||
|
Invert: true,
|
||||||
|
},
|
||||||
|
QuoteLeg: model.CrossRateLeg{
|
||||||
|
Pair: quoteLegPair,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo.rates = &ratesStoreStub{
|
||||||
|
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
switch pair {
|
||||||
|
case targetPair:
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
case baseLegPair:
|
||||||
|
return &model.RateSnapshot{
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
Ask: "0.90",
|
||||||
|
Bid: "0.90",
|
||||||
|
Mid: "0.90",
|
||||||
|
RateRef: "base-leg",
|
||||||
|
AsOfUnixMs: 1_000,
|
||||||
|
}, nil
|
||||||
|
case quoteLegPair:
|
||||||
|
return &model.RateSnapshot{
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
Ask: "90",
|
||||||
|
Bid: "90",
|
||||||
|
Mid: "90",
|
||||||
|
RateRef: "quote-leg",
|
||||||
|
AsOfUnixMs: 2_000,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.ErrNoData
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
repo.quotes = "esStoreStub{}
|
||||||
|
repo.currencies = currencyStoreStub{}
|
||||||
|
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.GetRate().GetMid().GetValue() != "100.00000000" {
|
||||||
|
t.Fatalf("unexpected mid price: %s", resp.GetRate().GetMid().GetValue())
|
||||||
|
}
|
||||||
|
if resp.GetRate().GetProvider() != "CROSSPROV" {
|
||||||
|
t.Fatalf("unexpected provider: %s", resp.GetRate().GetProvider())
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(resp.GetRate().GetRateRef(), "cross|") {
|
||||||
|
t.Fatalf("expected cross rate ref, got %s", resp.GetRate().GetRateRef())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidateQuote(t *testing.T) {
|
||||||
|
now := time.Now().Add(time.Minute)
|
||||||
|
repo := &repositoryStub{
|
||||||
|
quotes: "esStoreStub{
|
||||||
|
getFn: func(context.Context, string) (*model.Quote, error) {
|
||||||
|
return &model.Quote{
|
||||||
|
QuoteRef: "q1",
|
||||||
|
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
|
Side: model.QuoteSideBuyBaseSellQuote,
|
||||||
|
Price: "1.10",
|
||||||
|
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
|
||||||
|
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
|
||||||
|
ExpiresAtUnixMs: now.UnixMilli(),
|
||||||
|
Status: model.QuoteStatusIssued,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.ValidateQuote(context.Background(), &oraclev1.ValidateQuoteRequest{QuoteRef: "q1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GetValid() {
|
||||||
|
t.Fatalf("expected quote valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceConsumeQuoteExpired(t *testing.T) {
|
||||||
|
repo := &repositoryStub{
|
||||||
|
quotes: "esStoreStub{
|
||||||
|
consumeFn: func(context.Context, string, string, time.Time) (*model.Quote, error) {
|
||||||
|
return nil, storage.ErrQuoteExpired
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
_, err := svc.ConsumeQuote(context.Background(), &oraclev1.ConsumeQuoteRequest{QuoteRef: "q1", LedgerTxnRef: "ledger"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestRateSuccess(t *testing.T) {
|
||||||
|
repo := &repositoryStub{
|
||||||
|
rates: &ratesStoreStub{latestFn: func(_ context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||||
|
if pair != (model.CurrencyPair{Base: "USD", Quote: "EUR"}) {
|
||||||
|
t.Fatalf("unexpected pair: %v", pair)
|
||||||
|
}
|
||||||
|
if provider != "DEFAULT" {
|
||||||
|
t.Fatalf("unexpected provider: %s", provider)
|
||||||
|
}
|
||||||
|
return &model.RateSnapshot{Pair: pair, RateRef: "rate", Provider: provider}, nil
|
||||||
|
}},
|
||||||
|
pairs: &pairStoreStub{
|
||||||
|
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||||
|
return &model.Pair{
|
||||||
|
Pair: pair,
|
||||||
|
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||||
|
DefaultProvider: "DEFAULT",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.GetRate().GetRateRef() != "rate" {
|
||||||
|
t.Fatalf("unexpected rate ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceListPairs(t *testing.T) {
|
||||||
|
repo := &repositoryStub{
|
||||||
|
pairs: &pairStoreStub{listFn: func(context.Context) ([]*model.Pair, error) {
|
||||||
|
return []*model.Pair{{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
svc := NewService(zap.NewNop(), repo, nil)
|
||||||
|
|
||||||
|
resp, err := svc.ListPairs(context.Background(), &oraclev1.ListPairsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.GetPairs()) != 1 {
|
||||||
|
t.Fatalf("expected one pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
126
api/fx/oracle/internal/service/oracle/transform.go
Normal file
126
api/fx/oracle/internal/service/oracle/transform.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package oracle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
|
||||||
|
resp := &oraclev1.ResponseMeta{}
|
||||||
|
if meta == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp.RequestRef = meta.GetRequestRef()
|
||||||
|
resp.TraceRef = meta.GetTraceRef()
|
||||||
|
|
||||||
|
trace := meta.GetTrace()
|
||||||
|
if trace == nil {
|
||||||
|
trace = &tracev1.TraceContext{
|
||||||
|
RequestRef: meta.GetRequestRef(),
|
||||||
|
IdempotencyKey: meta.GetIdempotencyKey(),
|
||||||
|
TraceRef: meta.GetTraceRef(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Trace = trace
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oraclev1.Quote{
|
||||||
|
QuoteRef: q.QuoteRef,
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: q.Pair.Base, Quote: q.Pair.Quote},
|
||||||
|
Side: sideModelToProto(q.Side),
|
||||||
|
Price: decimalStringToProto(q.Price),
|
||||||
|
BaseAmount: moneyModelToProto(&q.BaseAmount),
|
||||||
|
QuoteAmount: moneyModelToProto(&q.QuoteAmount),
|
||||||
|
ExpiresAtUnixMs: q.ExpiresAtUnixMs,
|
||||||
|
Provider: q.Provider,
|
||||||
|
RateRef: q.RateRef,
|
||||||
|
Firm: q.Firm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyModelToProto(m *model.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{Currency: m.Currency, Amount: m.Amount}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sideModelToProto(side model.QuoteSide) fxv1.Side {
|
||||||
|
switch side {
|
||||||
|
case model.QuoteSideBuyBaseSellQuote:
|
||||||
|
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||||
|
case model.QuoteSideSellBaseBuyQuote:
|
||||||
|
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||||
|
default:
|
||||||
|
return fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rateModelToProto(rate *model.RateSnapshot) *oraclev1.RateSnapshot {
|
||||||
|
if rate == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &oraclev1.RateSnapshot{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: rate.Pair.Base, Quote: rate.Pair.Quote},
|
||||||
|
Mid: decimalStringToProto(rate.Mid),
|
||||||
|
Bid: decimalStringToProto(rate.Bid),
|
||||||
|
Ask: decimalStringToProto(rate.Ask),
|
||||||
|
AsofUnixMs: rate.AsOfUnixMs,
|
||||||
|
Provider: rate.Provider,
|
||||||
|
RateRef: rate.RateRef,
|
||||||
|
SpreadBps: decimalStringToProto(rate.SpreadBps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairModelToProto(pair *model.Pair) *oraclev1.PairMeta {
|
||||||
|
if pair == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &oraclev1.PairMeta{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: pair.Pair.Base, Quote: pair.Pair.Quote},
|
||||||
|
BaseMeta: currencySettingsToProto(&pair.BaseMeta),
|
||||||
|
QuoteMeta: currencySettingsToProto(&pair.QuoteMeta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currencySettingsToProto(c *model.CurrencySettings) *moneyv1.CurrencyMeta {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.CurrencyMeta{
|
||||||
|
Code: c.Code,
|
||||||
|
Decimals: c.Decimals,
|
||||||
|
Rounding: roundingModeToProto(c.Rounding),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundingModeToProto(mode model.RoundingMode) moneyv1.RoundingMode {
|
||||||
|
switch mode {
|
||||||
|
case model.RoundingModeHalfUp:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||||
|
case model.RoundingModeDown:
|
||||||
|
return moneyv1.RoundingMode_ROUND_DOWN
|
||||||
|
case model.RoundingModeHalfEven, model.RoundingModeUnspecified:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||||
|
default:
|
||||||
|
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalStringToProto(value string) *moneyv1.Decimal {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Decimal{Value: value}
|
||||||
|
}
|
||||||
17
api/fx/oracle/main.go
Normal file
17
api/fx/oracle/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/fx/oracle/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/fx/oracle/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)
|
||||||
|
}
|
||||||
2
api/fx/storage/.gitignore
vendored
Normal file
2
api/fx/storage/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
32
api/fx/storage/go.mod
Normal file
32
api/fx/storage/go.mod
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module github.com/tech/sendico/fx/storage
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
|
github.com/casbin/casbin/v2 v2.128.0 // indirect
|
||||||
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
177
api/fx/storage/go.sum
Normal file
177
api/fx/storage/go.sum
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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/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.128.0 h1:761dLmXLy/ZNSckAITvpUZ8VdrxARyIlwmdafHzRb7Y=
|
||||||
|
github.com/casbin/casbin/v2 v2.128.0/go.mod h1:iAwqzcYzJtAK5QWGT2uRl9WfRxXyKFBG1AZuhk2NAQg=
|
||||||
|
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/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-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/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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
|
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/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/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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
|
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.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
|
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
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.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
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/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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
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=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user