Compare commits
151 Commits
SEND005
...
a6374d1136
| Author | SHA1 | Date | |
|---|---|---|---|
| a6374d1136 | |||
|
|
7c864dc304 | ||
| 4aeb06fd31 | |||
|
|
d1786dc5d9 | ||
| f5bf8cf6d0 | |||
| 7daa4ab027 | |||
|
|
6f2309669b | ||
|
|
e4847cd137 | ||
| dbd06a4162 | |||
|
|
1ec6cd8386 | ||
| 6daf567baf | |||
| 23a57e543d | |||
|
|
8adfab94b5 | ||
|
|
db488a31e8 | ||
| 3836ff5ef3 | |||
| aef5c99a22 | |||
|
|
be7c965234 | ||
|
|
63448ab267 | ||
| 34a565d86d | |||
|
|
171d90b3f7 | ||
| 5191336a49 | |||
|
|
48f64a722d | ||
| bde453d106 | |||
|
|
3bb33b8895 | ||
| 8ee092089f | |||
|
|
eca3d0d62e | ||
| aba743406a | |||
|
|
deb29efde3 | ||
| 6995afc47d | |||
|
|
7b645a3bbe | ||
| 0ddd92b88b | |||
|
|
6151e3d3a5 | ||
| af7abbb095 | |||
|
|
71be1ef9f0 | ||
| 3df358d865 | |||
|
|
c6b2ba486b | ||
| d324e455cc | |||
|
|
8c87e5534e | ||
| bcb3e9e647 | |||
|
|
43f26143df | ||
| ed6e6bf1ba | |||
|
|
2d38b974ba | ||
|
|
610296b301 | ||
|
|
fcc68c8380 | ||
| b96babdfd4 | |||
| 69fdbf4e95 | |||
|
|
d32b2aa959 | ||
|
|
be10839e3a | ||
| d530af43a1 | |||
|
|
aa673fb26d | ||
| d978e24a9d | |||
|
|
31d93e5113 | ||
| f02f3449f3 | |||
|
|
d46822b9bb | ||
| 0505b2314e | |||
|
|
407e704352 | ||
| 4251dfb2c6 | |||
|
|
e0820c47c2 | ||
| 68b82cbca2 | |||
|
|
9e6d530385 | ||
| 5836292adb | |||
| 0c6229331f | |||
| 8cb6a64f2b | |||
|
|
4453dab366 | ||
|
|
512f25f74f | ||
|
|
43020f3eb6 | ||
| 964e90767d | |||
|
|
03cd2f4784 | ||
| 2d735aa7f5 | |||
|
|
342dd5328f | ||
| 915ed66b08 | |||
|
|
fe73b3078a | ||
| 76204822e7 | |||
|
|
77c205f9b2 | ||
| 6a29dc8907 | |||
|
|
8f1f279792 | ||
| 1f0b54d590 | |||
|
|
cefb9706f9 | ||
| 79b7899658 | |||
|
|
c941319c4e | ||
| e6626600cc | |||
|
|
e74c06e87a | ||
| c3647bfc46 | |||
|
|
3ff81038a9 | ||
| d6d9d47e67 | |||
|
|
034eb943e2 | ||
| 93bd0bf002 | |||
|
|
946bfa217c | ||
| 318255405b | |||
|
|
19d4ee1d33 | ||
| bc6a56c129 | |||
|
|
ec54579921 | ||
| 1ed76f7243 | |||
|
|
6527d183ec | ||
| 41b0dec460 | |||
|
|
d26ba84094 | ||
|
|
4073c8819c | ||
|
|
47ada0691c | ||
| 97c67670e5 | |||
|
|
dfad7fb335 | ||
| 41abf723e6 | |||
|
|
2d6586430f | ||
| d649748f6f | |||
|
|
61177a4e30 | ||
| c7b9b70d57 | |||
|
|
5030453807 | ||
| 5565081b69 | |||
| b216aa68b7 | |||
| 7ac1c519e3 | |||
| 076b0c6434 | |||
|
|
9a90e6a03b | ||
|
|
5218632c00 | ||
|
|
a2c05745ad | ||
|
|
82b2f88122 | ||
|
|
28d74d058b | ||
|
|
6ee146b95a | ||
| 67b52af150 | |||
|
|
058a3fefaf | ||
| c8a97d940c | |||
|
|
00045c1e65 | ||
| d64d7dab58 | |||
|
|
4746a00eee | ||
| 3f8399d647 | |||
|
|
028b29fe08 | ||
| cb3f59a9d5 | |||
|
|
2b8d02d95c | ||
| d90d8cda11 | |||
|
|
17333df7af | ||
| 681d53e856 | |||
|
|
dc608fd257 | ||
| cd2efdc2f3 | |||
|
|
fd47867101 | ||
| 2ca1a6956c | |||
|
|
a5ad4f4c3c | ||
| 8b202e0c60 | |||
|
|
4626d0a1a7 | ||
| da72121109 | |||
|
|
5bebadf17c | ||
| 1bab0b14ef | |||
|
|
39f323d050 | ||
| 7cd9e14618 | |||
|
|
b77d2c16ab | ||
|
|
324f150950 | ||
| dd6bcf843c | |||
|
|
874cc4971b | ||
| bfe4695b2d | |||
|
|
99161c8e7d | ||
| 6901791dd2 | |||
|
|
acb3d14b47 | ||
| aa5f7e271e | |||
|
|
0a01995f53 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ generate_protos.sh
|
||||
update_dep.sh
|
||||
.vscode/
|
||||
.gocache/
|
||||
.cache/
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ require (
|
||||
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/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -49,6 +49,6 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
@@ -95,8 +95,8 @@ 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/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -212,12 +212,12 @@ 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-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -90,10 +90,6 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -113,7 +109,8 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
||||
|
||||
entrySide := mapEntrySide(rule.EntrySide)
|
||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
|
||||
@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
if r.plans == nil {
|
||||
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||
}
|
||||
|
||||
// Try org-specific first if provided.
|
||||
if orgID != nil && !orgID.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
|
||||
if orgRef != nil && !orgRef.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||
return plan, rule, nil
|
||||
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
|
||||
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
|
||||
return nil, nil, selErr
|
||||
}
|
||||
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
|
||||
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex()))
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
|
||||
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||
}
|
||||
if !plan.GetOrganizationRef().IsZero() {
|
||||
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
|
||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||
}
|
||||
if rule.RuleID != "global_capture" {
|
||||
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||
@@ -59,8 +59,7 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
orgPlan.SetOrganizationRef(org)
|
||||
|
||||
orgPlan.OrganizationRef = &org
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
@@ -95,7 +94,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
plan.SetOrganizationRef(org)
|
||||
plan.OrganizationRef = &org
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
@@ -136,7 +135,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||
},
|
||||
}
|
||||
orgPlan.SetOrganizationRef(org)
|
||||
orgPlan.OrganizationRef = &org
|
||||
|
||||
globalPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
@@ -221,7 +220,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p1.SetOrganizationRef(org)
|
||||
p1.OrganizationRef = &org
|
||||
p2 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||
@@ -229,7 +228,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p2.SetOrganizationRef(org)
|
||||
p2.OrganizationRef = &org
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
@@ -263,7 +262,7 @@ func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.O
|
||||
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || plan.GetOrganizationRef() != orgRef {
|
||||
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
@@ -289,7 +288,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || !plan.GetOrganizationRef().IsZero() {
|
||||
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
|
||||
88
api/billing/fees/internal/service/fees/logging.go
Normal file
88
api/billing/fees/internal/service/fees/logging.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||
fields := logFieldsFromRequestMeta(meta)
|
||||
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 5)
|
||||
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||
}
|
||||
if base := intent.GetBaseAmount(); base != nil {
|
||||
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||
fields = append(fields, zap.String("base_amount", amount))
|
||||
}
|
||||
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||
fields = append(fields, zap.String("base_currency", currency))
|
||||
}
|
||||
}
|
||||
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||
}
|
||||
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||
fields = append(fields, zap.String("request_ref", reqRef))
|
||||
}
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if payload.ExpiresAtUnixMs > 0 {
|
||||
fields = append(fields,
|
||||
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||
}
|
||||
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||
return fields
|
||||
}
|
||||
@@ -72,26 +72,57 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
}
|
||||
|
||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
intent *feesv1.Intent
|
||||
)
|
||||
if req != nil {
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
}
|
||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("QuoteFees finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("QuoteFees request received")
|
||||
|
||||
if err = s.validateQuoteRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
@@ -112,20 +143,59 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
}
|
||||
|
||||
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
intent *feesv1.Intent
|
||||
)
|
||||
if req != nil {
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
var (
|
||||
fxUsed bool
|
||||
expiresAt time.Time
|
||||
)
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
if ts := resp.GetExpiresAt(); ts != nil {
|
||||
expiresAt = ts.AsTime()
|
||||
}
|
||||
}
|
||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("PrecomputeFees finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("PrecomputeFees request received")
|
||||
|
||||
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,6 +204,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,7 +219,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if ttl <= 0 {
|
||||
ttl = 60000
|
||||
}
|
||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
payload := feeQuoteTokenPayload{
|
||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||
@@ -159,7 +230,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
|
||||
var token string
|
||||
if token, err = encodeTokenPayload(payload); err != nil {
|
||||
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
|
||||
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
|
||||
}
|
||||
@@ -176,9 +247,18 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
}
|
||||
|
||||
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
||||
tokenLen := 0
|
||||
if req != nil {
|
||||
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||
}
|
||||
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
var fxUsed bool
|
||||
var (
|
||||
fxUsed bool
|
||||
resultReason string
|
||||
)
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
if err == nil && resp != nil {
|
||||
@@ -191,9 +271,28 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
}
|
||||
}
|
||||
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Bool("valid", resp != nil && resp.GetValid()),
|
||||
}
|
||||
if resultReason != "" {
|
||||
logFields = append(logFields, zap.String("reason", resultReason))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("ValidateFeeToken finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("ValidateFeeToken request received")
|
||||
|
||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||
resultReason = "missing_token"
|
||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||
return nil, err
|
||||
}
|
||||
@@ -202,21 +301,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
|
||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||
if decodeErr != nil {
|
||||
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
||||
resultReason = "invalid_token"
|
||||
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()
|
||||
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
|
||||
|
||||
if payload.Intent != nil {
|
||||
trigger = payload.Intent.GetTrigger()
|
||||
}
|
||||
|
||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||
resultReason = "expired"
|
||||
logger.Info("fee quote token expired")
|
||||
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))
|
||||
resultReason = "invalid_token"
|
||||
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -280,6 +387,16 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
bookedAt = intent.GetBookedAt().AsTime()
|
||||
}
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.Time("booked_at_used", bookedAt),
|
||||
}
|
||||
if !orgRef.IsZero() {
|
||||
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||
}
|
||||
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||
logger := s.logger.With(logFields...)
|
||||
|
||||
var orgPtr *primitive.ObjectID
|
||||
if !orgRef.IsZero() {
|
||||
orgPtr = &orgRef
|
||||
@@ -288,7 +405,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
|
||||
@@ -297,7 +414,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||
default:
|
||||
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||
logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
|
||||
}
|
||||
}
|
||||
@@ -313,7 +430,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
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))
|
||||
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
result := &types.CalculationResult{
|
||||
Lines: []*feesv1.DerivedPostingLine{
|
||||
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
fakeOracle := &oracleclient.Fake{
|
||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||
@@ -452,7 +452,7 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.O
|
||||
if s.plan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.GetOrganizationRef() != orgRef {
|
||||
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.Active {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,14 +26,14 @@ const (
|
||||
|
||||
// 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"`
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||
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.
|
||||
|
||||
@@ -11,12 +11,18 @@ market:
|
||||
- driver: CBR
|
||||
settings:
|
||||
base_url: "https://www.cbr.ru"
|
||||
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||
pairs:
|
||||
BINANCE:
|
||||
- base: "USDT"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDT"
|
||||
invert: true
|
||||
- base: "USD"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUSD"
|
||||
invert: true
|
||||
- base: "UAH"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUAH"
|
||||
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
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/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
@@ -49,7 +49,7 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -95,8 +95,8 @@ 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/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -212,12 +212,12 @@ 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-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Service) executePoll(ctx context.Context) error {
|
||||
|
||||
func (s *Service) pollOnce(ctx context.Context) error {
|
||||
var firstErr error
|
||||
failures := 0
|
||||
for _, pair := range s.pairs {
|
||||
start := time.Now()
|
||||
err := s.upsertPair(ctx, pair)
|
||||
@@ -96,14 +97,24 @@ func (s *Service) pollOnce(ctx context.Context) error {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
failures++
|
||||
s.logger.Warn("Failed to ingest pair",
|
||||
zap.String("symbol", pair.Symbol),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("base", pair.Base),
|
||||
zap.String("quote", pair.Quote),
|
||||
zap.Bool("invert", pair.Invert),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
|
||||
} else {
|
||||
s.logger.Info("Ingestion poll completed", zap.Int("total", len(s.pairs)))
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -115,7 +126,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
|
||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "fetch ticker")
|
||||
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
|
||||
}
|
||||
|
||||
bid, err := parseDecimal(ticker.BidPrice)
|
||||
@@ -172,9 +183,14 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
s.logger.Debug("Snapshot ingested",
|
||||
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.String("provider_ref", snapshot.ProviderRef),
|
||||
zap.String("bid", snapshot.Bid),
|
||||
zap.String("ask", snapshot.Ask),
|
||||
zap.String("mid", snapshot.Mid),
|
||||
zap.String("spread_bps", snapshot.SpreadBps),
|
||||
zap.Int64("asof_unix_ms", snapshot.AsOfUnixMs),
|
||||
zap.String("rate_ref", snapshot.RateRef),
|
||||
)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
type cbrConnector struct {
|
||||
id mmodel.Driver
|
||||
provider string
|
||||
client *http.Client
|
||||
http *httpClient
|
||||
base string
|
||||
dailyPath string
|
||||
directoryPath string
|
||||
@@ -60,6 +60,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
||||
directoryPath := defaultDirectoryPath
|
||||
dailyPath := defaultDailyPath
|
||||
dynamicPath := defaultDynamicPath
|
||||
userAgent := defaultUserAgent
|
||||
acceptHeader := defaultAccept
|
||||
|
||||
if settings != nil {
|
||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
@@ -77,6 +79,12 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
||||
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
dynamicPath = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
userAgent = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
acceptHeader = 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)
|
||||
@@ -99,13 +107,24 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
||||
transport = customTransport
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
}
|
||||
referer := parsed.String()
|
||||
|
||||
connector := &cbrConnector{
|
||||
id: mmodel.DriverCBR,
|
||||
provider: provider,
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
},
|
||||
http: newHTTPClient(
|
||||
logger,
|
||||
client,
|
||||
httpClientOptions{
|
||||
userAgent: userAgent,
|
||||
accept: acceptHeader,
|
||||
referer: referer,
|
||||
},
|
||||
),
|
||||
base: strings.TrimRight(parsed.String(), "/"),
|
||||
dailyPath: dailyPath,
|
||||
directoryPath: directoryPath,
|
||||
@@ -161,20 +180,32 @@ func (c *cbrConnector) refreshDirectory() error {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "cbr: build directory request")
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("CBR directory request failed", zap.Error(err))
|
||||
c.logger.Warn(
|
||||
"CBR directory request failed",
|
||||
zap.Error(err),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
)
|
||||
return merrors.InternalWrap(err, "cbr: directory request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||
c.logger.Warn(
|
||||
"CBR directory returned non-OK status",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
)
|
||||
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
@@ -183,12 +214,13 @@ func (c *cbrConnector) refreshDirectory() error {
|
||||
|
||||
var directory valuteDirectory
|
||||
if err := decoder.Decode(&directory); err != nil {
|
||||
c.logger.Warn("CBR directory decode failed", zap.Error(err))
|
||||
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
|
||||
return merrors.InternalWrap(err, "cbr: decode directory")
|
||||
}
|
||||
|
||||
mapping, err := buildValuteMapping(directory.Items)
|
||||
mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -200,23 +232,32 @@ func (c *cbrConnector) refreshDirectory() error {
|
||||
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
|
||||
endpoint, err := c.buildURL(c.dailyPath, nil)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
|
||||
return "", merrors.InternalWrap(err, "cbr: build daily request")
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
|
||||
c.logger.Warn("CBR daily request failed",
|
||||
zap.Error(err), zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
)
|
||||
return "", merrors.InternalWrap(err, "cbr: daily request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||
c.logger.Warn(
|
||||
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
|
||||
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
|
||||
)
|
||||
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
@@ -225,7 +266,9 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
|
||||
|
||||
var payload dailyRates
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.logger.Warn("CBR daily decode failed", zap.Error(err))
|
||||
c.logger.Warn("CBR daily decode failed", zap.Error(err),
|
||||
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
|
||||
)
|
||||
return "", merrors.InternalWrap(err, "cbr: decode daily response")
|
||||
}
|
||||
|
||||
@@ -247,25 +290,40 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
|
||||
"date_req2": date.Format("02/01/2006"),
|
||||
"VAL_NM_RQ": valute.ID,
|
||||
}
|
||||
dateStr := date.Format("2006-01-02")
|
||||
endpoint, err := c.buildURL(c.dynamicPath, query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||
if err != nil {
|
||||
return "", merrors.InternalWrap(err, "cbr: build historical request")
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
|
||||
c.logger.Warn(
|
||||
"CBR historical request failed",
|
||||
zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("date", dateStr),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", merrors.InternalWrap(err, "cbr: historical request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||
c.logger.Warn(
|
||||
"CBR historical returned non-OK status",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("date", dateStr),
|
||||
zap.String("endpoint", endpoint),
|
||||
)
|
||||
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
@@ -274,7 +332,13 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
|
||||
|
||||
var payload dynamicRates
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.logger.Warn("CBR historical decode failed", zap.Error(err))
|
||||
c.logger.Warn(
|
||||
"CBR historical decode failed",
|
||||
zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("date", dateStr),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", merrors.InternalWrap(err, "cbr: decode historical response")
|
||||
}
|
||||
|
||||
@@ -337,7 +401,7 @@ type valuteMapping struct {
|
||||
byID map[string]valuteInfo
|
||||
}
|
||||
|
||||
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
||||
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
|
||||
byISO := make(map[string]valuteInfo, len(items))
|
||||
byID := make(map[string]valuteInfo, len(items))
|
||||
byNum := make(map[string]string, len(items))
|
||||
@@ -348,12 +412,19 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
||||
isoNum := strings.TrimSpace(item.ISONum)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
engName := strings.TrimSpace(item.EngName)
|
||||
|
||||
nominal, err := parseNominal(item.NominalStr)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
|
||||
}
|
||||
|
||||
if id == "" || isoChar == "" {
|
||||
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
|
||||
logger.Info("Skipping invalid currency entry",
|
||||
zap.String("id", id),
|
||||
zap.String("iso_char", isoChar),
|
||||
zap.String("name", name),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
info := valuteInfo{
|
||||
@@ -365,12 +436,76 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
||||
Nominal: nominal,
|
||||
}
|
||||
|
||||
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
|
||||
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
|
||||
if existing, ok := byISO[isoChar]; ok {
|
||||
// Same ISO + same ID: duplicate entry, just ignore.
|
||||
if existing.ID == id {
|
||||
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
|
||||
zap.String("iso_code", isoChar),
|
||||
zap.String("id", id),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Different IDs but same ISO char.
|
||||
// Choose canonical entry:
|
||||
// 1) Prefer nominal == 1
|
||||
// 2) Otherwise prefer smaller nominal
|
||||
keepExisting := true
|
||||
|
||||
if existing.Nominal != 1 && info.Nominal == 1 {
|
||||
keepExisting = false
|
||||
} else if existing.Nominal == 1 && info.Nominal != 1 {
|
||||
keepExisting = true
|
||||
} else if info.Nominal < existing.Nominal {
|
||||
keepExisting = false
|
||||
}
|
||||
|
||||
if keepExisting {
|
||||
logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
|
||||
zap.String("iso_code", isoChar),
|
||||
zap.String("existing_id", existing.ID),
|
||||
zap.Int64("existing_nominal", existing.Nominal),
|
||||
zap.String("new_id", info.ID),
|
||||
zap.Int64("new_nominal", info.Nominal),
|
||||
)
|
||||
// We keep the old one, just skip the new.
|
||||
continue
|
||||
}
|
||||
|
||||
// Replace existing mapping with the new, more canonical one.
|
||||
logger.Warn("Replacing currency mapping due to more canonical nominal",
|
||||
zap.String("iso_code", isoChar),
|
||||
zap.String("old_id", existing.ID),
|
||||
zap.Int64("old_nominal", existing.Nominal),
|
||||
zap.String("new_id", info.ID),
|
||||
zap.Int64("new_nominal", info.Nominal),
|
||||
)
|
||||
|
||||
// Update byID: drop old ID, add new one
|
||||
delete(byID, existing.ID)
|
||||
byID[id] = info
|
||||
|
||||
// Update ISO mapping
|
||||
byISO[isoChar] = info
|
||||
|
||||
// Update numeric-code index if present
|
||||
if isoNum != "" {
|
||||
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||
}
|
||||
byNum[isoNum] = id
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// No existing ISO entry, do normal uniqueness checks.
|
||||
|
||||
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
|
||||
}
|
||||
|
||||
if isoNum != "" {
|
||||
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||
@@ -378,6 +513,8 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
||||
byNum[isoNum] = id
|
||||
}
|
||||
|
||||
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
|
||||
|
||||
byISO[isoChar] = info
|
||||
byID[id] = info
|
||||
}
|
||||
|
||||
85
api/fx/ingestor/internal/market/cbr/http_client.go
Normal file
85
api/fx/ingestor/internal/market/cbr/http_client.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package cbr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUserAgent = "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||
defaultAccept = "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||
)
|
||||
|
||||
// httpClient wraps http.Client to ensure CBR requests always carry required headers.
|
||||
type httpClient struct {
|
||||
client *http.Client
|
||||
headers http.Header
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type httpClientOptions struct {
|
||||
userAgent string
|
||||
accept string
|
||||
referer string
|
||||
}
|
||||
|
||||
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
|
||||
userAgent := opts.userAgent
|
||||
if strings.TrimSpace(userAgent) == "" {
|
||||
userAgent = defaultUserAgent
|
||||
}
|
||||
|
||||
accept := opts.accept
|
||||
if strings.TrimSpace(accept) == "" {
|
||||
accept = defaultAccept
|
||||
}
|
||||
|
||||
referer := opts.referer
|
||||
if strings.TrimSpace(referer) == "" {
|
||||
referer = defaultCBRBaseURL
|
||||
}
|
||||
l := logger.Named("http_client")
|
||||
|
||||
headers := make(http.Header, 3)
|
||||
headers.Set("User-Agent", userAgent)
|
||||
headers.Set("Accept", accept)
|
||||
headers.Set("Referer", referer)
|
||||
|
||||
l.Info("HTTP client initialized", zap.String("user_agent", userAgent),
|
||||
zap.String("accept", accept), zap.String("referrer", referer))
|
||||
|
||||
return &httpClient{
|
||||
client: client,
|
||||
headers: headers,
|
||||
logger: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *httpClient) NewRequest(ctx context.Context, method, endpoint string) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, method, endpoint, nil)
|
||||
}
|
||||
|
||||
func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
enriched := req.Clone(req.Context())
|
||||
for key, values := range h.headers {
|
||||
if enriched.Header.Get(key) != "" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
enriched.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
r, err := h.client.Do(enriched)
|
||||
if err != nil {
|
||||
h.logger.Warn("HTTP request failed", zap.Error(err), zap.String("method", req.Method),
|
||||
zap.String("url", req.URL.String()))
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (h *httpClient) headerValue(name string) string {
|
||||
return h.headers.Get(name)
|
||||
}
|
||||
@@ -40,15 +40,15 @@ func main() {
|
||||
|
||||
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.Error("Failed to initialise application", zap.Error(err))
|
||||
} else {
|
||||
if err := application.Run(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Info("FX ingestor stopped")
|
||||
return
|
||||
}
|
||||
logger.Error("Ingestor terminated with error", zap.Error(err))
|
||||
}
|
||||
logger.Fatal("Ingestor terminated with error", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("FX ingestor stopped")
|
||||
|
||||
@@ -13,8 +13,8 @@ require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ require (
|
||||
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/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
@@ -50,5 +50,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
)
|
||||
|
||||
@@ -95,8 +95,8 @@ 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/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -212,12 +212,12 @@ 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-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
90
api/fx/oracle/internal/service/oracle/logging.go
Normal file
90
api/fx/oracle/internal/service/oracle/logging.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func quoteRequestFields(req *oraclev1.GetQuoteRequest) []zap.Field {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
fields := requestMetaFields(req.GetMeta())
|
||||
if pair := req.GetPair(); pair != nil {
|
||||
if base := strings.TrimSpace(pair.GetBase()); base != "" {
|
||||
fields = append(fields, zap.String("pair_base", base))
|
||||
}
|
||||
if quote := strings.TrimSpace(pair.GetQuote()); quote != "" {
|
||||
fields = append(fields, zap.String("pair_quote", quote))
|
||||
}
|
||||
}
|
||||
if side := req.GetSide(); side != fxv1.Side_SIDE_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("side", side.String()))
|
||||
}
|
||||
if req.GetFirm() {
|
||||
fields = append(fields, zap.Bool("firm", req.GetFirm()))
|
||||
}
|
||||
if ttl := req.GetTtlMs(); ttl > 0 {
|
||||
fields = append(fields, zap.Int64("ttl_ms", ttl))
|
||||
}
|
||||
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||
fields = append(fields, zap.Int32("max_age_ms", maxAge))
|
||||
}
|
||||
if provider := strings.TrimSpace(req.GetPreferredProvider()); provider != "" {
|
||||
fields = append(fields, zap.String("preferred_provider", provider))
|
||||
}
|
||||
fields = append(fields, moneyFields("base_amount", req.GetBaseAmount())...)
|
||||
fields = append(fields, moneyFields("quote_amount", req.GetQuoteAmount())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func requestMetaFields(meta *oraclev1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if tenant := strings.TrimSpace(meta.GetTenantRef()); tenant != "" {
|
||||
fields = append(fields, zap.String("tenant_ref", tenant))
|
||||
}
|
||||
fields = append(fields, traceFields(meta.GetTrace())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func moneyFields(prefix string, money *moneyv1.Money) []zap.Field {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 2)
|
||||
if amt := strings.TrimSpace(money.GetAmount()); amt != "" {
|
||||
fields = append(fields, zap.String(prefix, amt))
|
||||
}
|
||||
if ccy := strings.TrimSpace(money.GetCurrency()); ccy != "" {
|
||||
fields = append(fields, zap.String(prefix+"_currency", ccy))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func traceFields(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if ref := strings.TrimSpace(trace.GetTraceRef()); ref != "" {
|
||||
fields = append(fields, zap.String("trace_ref", ref))
|
||||
}
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
if req := strings.TrimSpace(trace.GetRequestRef()); req != "" {
|
||||
fields = append(fields, zap.String("request_ref", req))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
@@ -101,22 +101,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
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()))
|
||||
logger := s.logger.With(quoteRequestFields(req)...)
|
||||
logger.Debug("Handling GetQuote")
|
||||
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
logger.Warn("GetQuote invalid: side missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
||||
}
|
||||
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
|
||||
logger.Warn("GetQuote invalid: both base_amount and quote_amount provided")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
|
||||
}
|
||||
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
||||
logger.Warn("GetQuote invalid: amount missing")
|
||||
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))
|
||||
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()) == "" {
|
||||
logger.Warn("GetQuote invalid: pair missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
@@ -125,8 +130,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("Pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||
default:
|
||||
logger.Warn("GetQuote failed to load pair", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +150,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("Rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||
default:
|
||||
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -153,27 +162,31 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
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))
|
||||
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 {
|
||||
logger.Warn("GetQuote invalid input", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
if req.GetBaseAmount() != nil {
|
||||
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
|
||||
logger.Warn("GetQuote invalid base input", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
} else if req.GetQuoteAmount() != nil {
|
||||
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
|
||||
logger.Warn("GetQuote invalid quote input", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := comp.compute(); err != nil {
|
||||
logger.Warn("GetQuote computation failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
@@ -195,12 +208,14 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
logger.Warn("GetQuote conflict issuing firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
|
||||
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("GetQuote failed to issue firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
|
||||
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))
|
||||
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{
|
||||
@@ -214,18 +229,24 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
||||
if req == nil {
|
||||
req = &oraclev1.ValidateQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
|
||||
logger = logger.With(zap.String("quote_ref", ref))
|
||||
}
|
||||
logger.Debug("Handling ValidateQuote")
|
||||
if req.GetQuoteRef() == "" {
|
||||
logger.Warn("ValidateQuote invalid: quote_ref missing")
|
||||
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))
|
||||
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):
|
||||
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
|
||||
resp := &oraclev1.ValidateQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: nil,
|
||||
@@ -234,6 +255,7 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
default:
|
||||
logger.Warn("ValidateQuote failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -255,6 +277,11 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
||||
Valid: valid,
|
||||
Reason: reason,
|
||||
}
|
||||
if !valid {
|
||||
logger.Info("ValidateQuote invalid", zap.String("reason", reason), zap.Bool("firm", quote.Firm))
|
||||
} else {
|
||||
logger.Debug("ValidateQuote valid", zap.Bool("firm", quote.Firm))
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -262,29 +289,43 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
|
||||
if req == nil {
|
||||
req = &oraclev1.ConsumeQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
|
||||
logger = logger.With(zap.String("quote_ref", ref))
|
||||
}
|
||||
if ledger := strings.TrimSpace(req.GetLedgerTxnRef()); ledger != "" {
|
||||
logger = logger.With(zap.String("ledger_txn_ref", ledger))
|
||||
}
|
||||
logger.Debug("Handling ConsumeQuote")
|
||||
if req.GetQuoteRef() == "" {
|
||||
logger.Warn("ConsumeQuote invalid: quote_ref missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if req.GetLedgerTxnRef() == "" {
|
||||
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
|
||||
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))
|
||||
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):
|
||||
logger.Warn("ConsumeQuote failed: expired")
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
||||
case errors.Is(err, storage.ErrQuoteConsumed):
|
||||
logger.Warn("ConsumeQuote failed: already consumed")
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
||||
case errors.Is(err, storage.ErrQuoteNotFirm):
|
||||
logger.Warn("ConsumeQuote failed: quote not firm")
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("ConsumeQuote failed: quote not found")
|
||||
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("ConsumeQuote failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -294,7 +335,7 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
|
||||
Consumed: true,
|
||||
Reason: "consumed",
|
||||
}
|
||||
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
logger.Info("Quote consumed")
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -302,13 +343,21 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
if req == nil {
|
||||
req = &oraclev1.LatestRateRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
if pair := req.GetPair(); pair != nil {
|
||||
logger = logger.With(zap.String("pair_base", strings.TrimSpace(pair.GetBase())), zap.String("pair_quote", strings.TrimSpace(pair.GetQuote())))
|
||||
}
|
||||
if provider := strings.TrimSpace(req.GetProvider()); provider != "" {
|
||||
logger = logger.With(zap.String("provider", provider))
|
||||
}
|
||||
logger.Debug("Handling LatestRate")
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
|
||||
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()) == "" {
|
||||
logger.Warn("LatestRate invalid: pair missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
@@ -317,8 +366,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("LatestRate pair not found")
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("LatestRate failed to load pair", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -335,8 +386,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("LatestRate not found", zap.String("provider", provider))
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("LatestRate failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -345,6 +398,7 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Rate: rateModelToProto(rate),
|
||||
}
|
||||
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -352,13 +406,15 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
|
||||
if req == nil {
|
||||
req = &oraclev1.ListPairsRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ListPairs")
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
logger.Debug("Handling ListPairs")
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
|
||||
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 {
|
||||
logger.Warn("ListPairs failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
result := make([]*oraclev1.PairMeta, 0, len(pairs))
|
||||
@@ -369,7 +425,7 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Pairs: result,
|
||||
}
|
||||
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||
logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@ require (
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
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=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/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=
|
||||
|
||||
@@ -50,28 +50,28 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
defer cancel()
|
||||
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
s.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||
s.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ratesStore, err := store.NewRates(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize rates store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize rates store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize quotes store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize quotes store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
pairsStore, err := store.NewPair(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize pair store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize pair store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
currencyStore, err := store.NewCurrency(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize currency store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize currency store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
s.pairs = pairsStore
|
||||
s.currencies = currencyStore
|
||||
|
||||
s.logger.Info("mongo storage ready")
|
||||
s.logger.Info("Mongo storage ready")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(index); err != nil {
|
||||
logger.Error("failed to ensure currencies index", zap.Error(err))
|
||||
logger.Error("Failed to ensure currencies index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
childLogger := logger.Named(model.CurrenciesCollection)
|
||||
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
||||
childLogger.Debug("Currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
||||
|
||||
return ¤cyStore{
|
||||
logger: childLogger,
|
||||
@@ -43,17 +43,17 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
|
||||
|
||||
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||
if code == "" {
|
||||
c.logger.Warn("attempt to fetch currency with empty code")
|
||||
c.logger.Warn("Attempt to fetch currency with empty code")
|
||||
return nil, merrors.InvalidArgument("currencyStore: empty code")
|
||||
}
|
||||
result := &model.Currency{}
|
||||
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.logger.Debug("currency not found", zap.String("code", code))
|
||||
c.logger.Debug("Currency not found", zap.String("code", code))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug("currency loaded", zap.String("code", code))
|
||||
c.logger.Debug("Currency loaded", zap.String("code", code))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -77,20 +77,20 @@ func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Cur
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error("failed to list currencies", zap.Error(err))
|
||||
c.logger.Warn("Failed to list currencies", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug("listed currencies", zap.Int("count", len(currencies)))
|
||||
c.logger.Debug("Listed currencies", zap.Int("count", len(currencies)))
|
||||
return currencies, nil
|
||||
}
|
||||
|
||||
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
|
||||
if currency == nil {
|
||||
c.logger.Warn("attempt to upsert nil currency")
|
||||
c.logger.Warn("Attempt to upsert nil currency")
|
||||
return merrors.InvalidArgument("currencyStore: nil currency")
|
||||
}
|
||||
if currency.Code == "" {
|
||||
c.logger.Warn("attempt to upsert currency with empty code")
|
||||
c.logger.Warn("Attempt to upsert currency with empty code")
|
||||
return merrors.InvalidArgument("currencyStore: empty code")
|
||||
}
|
||||
|
||||
@@ -98,16 +98,16 @@ func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) er
|
||||
filter := repository.Filter("code", currency.Code)
|
||||
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.logger.Debug("inserting new currency", zap.String("code", currency.Code))
|
||||
c.logger.Debug("Inserting new currency", zap.String("code", currency.Code))
|
||||
return c.repo.Insert(ctx, currency, filter)
|
||||
}
|
||||
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
||||
c.logger.Warn("Failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
currency.SetID(*existing.GetID())
|
||||
}
|
||||
c.logger.Debug("updating currency", zap.String("code", currency.Code))
|
||||
c.logger.Debug("Updating currency", zap.String("code", currency.Code))
|
||||
return c.repo.Update(ctx, currency)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, erro
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(index); err != nil {
|
||||
logger.Error("failed to ensure pairs index", zap.Error(err))
|
||||
logger.Error("Failed to ensure pairs index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection))
|
||||
logger.Debug("Pair store initialised", zap.String("collection", model.PairsCollection))
|
||||
|
||||
return &pairStore{
|
||||
logger: logger.Named(model.PairsCollection),
|
||||
@@ -53,16 +53,16 @@ func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logger.Error("failed to list enabled pairs", zap.Error(err))
|
||||
p.logger.Warn("Failed to list enabled pairs", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs)))
|
||||
p.logger.Debug("Listed enabled pairs", zap.Int("count", len(pairs)))
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair.Base == "" || pair.Quote == "" {
|
||||
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
p.logger.Warn("Attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
|
||||
}
|
||||
result := &model.Pair{}
|
||||
@@ -71,21 +71,21 @@ func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pa
|
||||
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
|
||||
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
p.logger.Debug("Pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
p.logger.Debug("Pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
||||
if pair == nil {
|
||||
p.logger.Warn("attempt to upsert nil pair")
|
||||
p.logger.Warn("Attempt to upsert nil pair")
|
||||
return merrors.InvalidArgument("pairStore: nil pair")
|
||||
}
|
||||
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
|
||||
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Warn("Attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return merrors.InvalidArgument("pairStore: incomplete pair")
|
||||
}
|
||||
|
||||
@@ -96,16 +96,16 @@ func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
||||
err := p.repo.FindOneByFilter(ctx, query, existing)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Debug("Inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return p.repo.Insert(ctx, pair, query)
|
||||
}
|
||||
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Warn("Failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
pair.SetID(*existing.GetID())
|
||||
}
|
||||
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Debug("Updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return p.repo.Update(ctx, pair)
|
||||
}
|
||||
|
||||
@@ -56,12 +56,12 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure quotes index", zap.Error(err))
|
||||
logger.Error("Failed to ensure quotes index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
childLogger := logger.Named(model.QuotesCollection)
|
||||
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection))
|
||||
childLogger.Debug("Quotes store initialised", zap.String("collection", model.QuotesCollection))
|
||||
|
||||
return "esStore{
|
||||
logger: childLogger,
|
||||
@@ -72,11 +72,11 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
||||
|
||||
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
if quote == nil {
|
||||
q.logger.Warn("attempt to issue nil quote")
|
||||
q.logger.Warn("Attempt to issue nil quote")
|
||||
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||
}
|
||||
if quote.QuoteRef == "" {
|
||||
q.logger.Warn("attempt to issue quote with empty ref")
|
||||
q.logger.Warn("Attempt to issue quote with empty ref")
|
||||
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
|
||||
@@ -89,32 +89,32 @@ func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
quote.ConsumedByLedgerTxnRef = ""
|
||||
quote.ConsumedAtUnixMs = nil
|
||||
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
|
||||
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
||||
q.logger.Warn("Failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
||||
return err
|
||||
}
|
||||
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
||||
q.logger.Debug("Quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
|
||||
if quoteRef == "" {
|
||||
q.logger.Warn("attempt to fetch quote with empty ref")
|
||||
q.logger.Warn("Attempt to fetch quote with empty ref")
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
quote := &model.Quote{}
|
||||
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef))
|
||||
q.logger.Debug("Quote not found", zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
||||
q.logger.Debug("Quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
|
||||
if quoteRef == "" || ledgerTxnRef == "" {
|
||||
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Warn("Attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
when = time.Now()
|
||||
}
|
||||
|
||||
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Debug("Consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
txn := q.txFactory.CreateTransaction()
|
||||
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
quote := &model.Quote{}
|
||||
@@ -131,7 +131,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
}
|
||||
|
||||
if !quote.Firm {
|
||||
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef))
|
||||
q.logger.Warn("Quote not firm", zap.String("quote_ref", quoteRef))
|
||||
return nil, storage.ErrQuoteNotFirm
|
||||
}
|
||||
|
||||
@@ -140,16 +140,16 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef))
|
||||
q.logger.Info("Quote expired during consume", zap.String("quote_ref", quoteRef))
|
||||
return nil, storage.ErrQuoteExpired
|
||||
}
|
||||
|
||||
if quote.Status == model.QuoteStatusConsumed {
|
||||
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
|
||||
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Debug("Quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return quote, nil
|
||||
}
|
||||
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
||||
q.logger.Warn("Quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
||||
return nil, storage.ErrQuoteConsumed
|
||||
}
|
||||
|
||||
@@ -157,11 +157,11 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Info("Quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return quote, nil
|
||||
})
|
||||
if err != nil {
|
||||
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Warn("Quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return nil, err
|
||||
}
|
||||
quote, _ := result.(*model.Quote)
|
||||
@@ -173,7 +173,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
|
||||
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||
if cutoff.IsZero() {
|
||||
q.logger.Warn("attempt to expire quotes with zero cutoff")
|
||||
q.logger.Warn("Attempt to expire quotes with zero cutoff")
|
||||
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
|
||||
}
|
||||
|
||||
@@ -188,11 +188,11 @@ func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time)
|
||||
|
||||
updated, err := q.repo.PatchMany(ctx, filter, patch)
|
||||
if err != nil {
|
||||
q.logger.Error("failed to expire quotes", zap.Error(err))
|
||||
q.logger.Warn("Failed to expire quotes", zap.Error(err))
|
||||
return 0, err
|
||||
}
|
||||
if updated > 0 {
|
||||
q.logger.Info("quotes expired", zap.Int("count", updated))
|
||||
q.logger.Info("Quotes expired", zap.Int("count", updated))
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure rates index", zap.Error(err))
|
||||
logger.Error("Failed to ensure rates index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection))
|
||||
logger.Debug("Rates store initialised", zap.String("collection", model.RatesCollection))
|
||||
return &ratesStore{
|
||||
logger: logger.Named(model.RatesCollection),
|
||||
repo: repo,
|
||||
@@ -64,11 +64,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
|
||||
|
||||
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||
if snapshot == nil {
|
||||
r.logger.Warn("attempt to upsert nil snapshot")
|
||||
r.logger.Warn("Attempt to upsert nil snapshot")
|
||||
return merrors.InvalidArgument("ratesStore: nil snapshot")
|
||||
}
|
||||
if snapshot.RateRef == "" {
|
||||
r.logger.Warn("attempt to upsert snapshot with empty rate_ref")
|
||||
r.logger.Warn("Attempt to upsert snapshot with empty rate_ref")
|
||||
return merrors.InvalidArgument("ratesStore: empty rateRef")
|
||||
}
|
||||
|
||||
@@ -82,17 +82,17 @@ func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSna
|
||||
err := r.repo.FindOneByFilter(ctx, filter, existing)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
r.logger.Debug("Inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
return r.repo.Insert(ctx, snapshot, filter)
|
||||
}
|
||||
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
||||
r.logger.Warn("Failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
snapshot.SetID(*existing.GetID())
|
||||
}
|
||||
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
r.logger.Debug("Updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
return r.repo.Update(ctx, snapshot)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type Client interface {
|
||||
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||
ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||
EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -36,6 +38,8 @@ type grpcGatewayClient interface {
|
||||
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
||||
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
||||
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
||||
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
|
||||
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
|
||||
}
|
||||
|
||||
type chainGatewayClient struct {
|
||||
@@ -139,6 +143,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain
|
||||
return c.client.EstimateTransferFee(ctx, req)
|
||||
}
|
||||
|
||||
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.ComputeGasTopUp(ctx, req)
|
||||
}
|
||||
|
||||
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.EnsureGasTopUp(ctx, req)
|
||||
}
|
||||
|
||||
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.cfg.CallTimeout
|
||||
if timeout <= 0 {
|
||||
|
||||
@@ -16,6 +16,8 @@ type Fake struct {
|
||||
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||
ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||
EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||
CloseFn func() error
|
||||
}
|
||||
|
||||
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
|
||||
return &chainv1.EstimateTransferFeeResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
if f.ComputeGasTopUpFn != nil {
|
||||
return f.ComputeGasTopUpFn(ctx, req)
|
||||
}
|
||||
return &chainv1.ComputeGasTopUpResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
if f.EnsureGasTopUpFn != nil {
|
||||
return f.EnsureGasTopUpFn(ctx, req)
|
||||
}
|
||||
return &chainv1.EnsureGasTopUpResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error {
|
||||
if f.CloseFn != nil {
|
||||
return f.CloseFn()
|
||||
|
||||
@@ -34,16 +34,23 @@ messaging:
|
||||
reconnect_wait: 5
|
||||
|
||||
chains:
|
||||
- name: arbitrum_one
|
||||
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
||||
- name: tron_mainnet
|
||||
chain_id: 728126428 # 0x2b6653dc
|
||||
native_token: TRX
|
||||
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
||||
gas_topup_policy:
|
||||
buffer_percent: 0.10
|
||||
min_native_balance_trx: 10
|
||||
rounding_unit_trx: 1
|
||||
max_topup_trx: 100
|
||||
tokens:
|
||||
- symbol: USDC
|
||||
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
|
||||
- symbol: USDT
|
||||
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
|
||||
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
- symbol: USDC
|
||||
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
|
||||
|
||||
service_wallet:
|
||||
chain: arbitrum_one
|
||||
chain: tron_mainnet
|
||||
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
||||
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
||||
|
||||
@@ -58,3 +65,4 @@ key_management:
|
||||
|
||||
cache:
|
||||
wallet_balance_ttl_seconds: 120
|
||||
rpc_request_timeout_seconds: 15
|
||||
|
||||
@@ -15,14 +15,14 @@ require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
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-20251208031133-be43a854e4be // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
@@ -60,7 +60,7 @@ require (
|
||||
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/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
@@ -86,5 +86,5 @@ require (
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/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-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/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=
|
||||
@@ -207,8 +207,8 @@ 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/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -362,12 +362,12 @@ 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-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -45,22 +45,22 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
}
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
logger.Error("vault address missing")
|
||||
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")
|
||||
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 missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||
}
|
||||
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
||||
if mountPath == "" {
|
||||
logger.Error("vault mount path missing")
|
||||
logger.Error("Vault mount path missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
||||
}
|
||||
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||
@@ -73,7 +73,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
|
||||
client, err := api.NewClient(clientCfg)
|
||||
if err != nil {
|
||||
logger.Error("failed to create vault client", zap.Error(err))
|
||||
logger.Error("Failed to create vault client", zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
||||
}
|
||||
client.SetToken(token)
|
||||
@@ -94,17 +94,17 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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)
|
||||
@@ -115,7 +115,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
|
||||
|
||||
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))
|
||||
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
|
||||
@@ -123,7 +123,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
|
||||
m.logger.Info("managed wallet key created",
|
||||
m.logger.Info("Managed wallet key created",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("network", network),
|
||||
zap.String("address", strings.ToLower(address)),
|
||||
@@ -158,43 +158,43 @@ func (m *Manager) buildKeyID(network, walletRef string) string {
|
||||
// SignTransaction loads the key material from Vault and signs the transaction.
|
||||
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")
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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",
|
||||
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()),
|
||||
@@ -213,23 +213,23 @@ func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, erro
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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
|
||||
|
||||
@@ -2,14 +2,18 @@ package serverimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
||||
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage"
|
||||
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
||||
@@ -30,6 +34,8 @@ type Imp struct {
|
||||
|
||||
config *config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
|
||||
rpcClients *rpcclient.Clients
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -41,11 +47,12 @@ type config struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
Name string `yaml:"name"`
|
||||
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||
ChainID uint64 `yaml:"chain_id"`
|
||||
NativeToken string `yaml:"native_token"`
|
||||
Tokens []tokenConfig `yaml:"tokens"`
|
||||
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
|
||||
}
|
||||
|
||||
type serviceWalletConfig struct {
|
||||
@@ -61,6 +68,19 @@ type tokenConfig struct {
|
||||
ContractEnv string `yaml:"contract_env"`
|
||||
}
|
||||
|
||||
type gasTopUpPolicyConfig struct {
|
||||
gasTopUpRuleConfig `yaml:",inline"`
|
||||
Native *gasTopUpRuleConfig `yaml:"native"`
|
||||
Contract *gasTopUpRuleConfig `yaml:"contract"`
|
||||
}
|
||||
|
||||
type gasTopUpRuleConfig struct {
|
||||
BufferPercent float64 `yaml:"buffer_percent"`
|
||||
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
|
||||
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
|
||||
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
|
||||
}
|
||||
|
||||
// Create initialises the chain gateway server implementation.
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
@@ -84,6 +104,9 @@ func (i *Imp) Shutdown() {
|
||||
defer cancel()
|
||||
|
||||
i.app.Shutdown(ctx)
|
||||
if i.rpcClients != nil {
|
||||
i.rpcClients.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
@@ -98,20 +121,34 @@ func (i *Imp) Start() error {
|
||||
}
|
||||
|
||||
cl := i.logger.Named("config")
|
||||
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||
if err != nil {
|
||||
i.logger.Error("Invalid chain network configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to prepare rpc clients", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
i.rpcClients = rpcClients
|
||||
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
||||
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
|
||||
opts := []gatewayservice.Option{
|
||||
gatewayservice.WithNetworks(networkConfigs),
|
||||
gatewayservice.WithServiceWallet(walletConfig),
|
||||
gatewayservice.WithKeyManager(keyManager),
|
||||
gatewayservice.WithTransferExecutor(executor),
|
||||
gatewayservice.WithRPCClients(rpcClients),
|
||||
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||
gatewayservice.WithSettings(cfg.Settings),
|
||||
}
|
||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||
@@ -129,7 +166,7 @@ func (i *Imp) Start() error {
|
||||
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))
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -137,7 +174,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
Config: &grpcapp.Config{},
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("failed to parse configuration", zap.Error(err))
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -157,22 +194,23 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
|
||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
|
||||
result := make([]gatewayshared.Network, 0, len(chains))
|
||||
for _, chain := range chains {
|
||||
if strings.TrimSpace(chain.Name) == "" {
|
||||
logger.Warn("skipping unnamed chain configuration")
|
||||
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))
|
||||
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
|
||||
}
|
||||
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
||||
for _, token := range chain.Tokens {
|
||||
symbol := strings.TrimSpace(token.Symbol)
|
||||
if symbol == "" {
|
||||
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name))
|
||||
logger.Warn("Skipping token with empty symbol", zap.String("chain", chain.Name))
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(token.Contract)
|
||||
@@ -182,9 +220,9 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
||||
}
|
||||
if addr == "" {
|
||||
if env != "" {
|
||||
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
||||
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
||||
} else {
|
||||
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
||||
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -194,15 +232,84 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
||||
})
|
||||
}
|
||||
|
||||
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
|
||||
if err != nil {
|
||||
logger.Error("Invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, gatewayshared.Network{
|
||||
Name: chain.Name,
|
||||
RPCURL: rpcURL,
|
||||
ChainID: chain.ChainID,
|
||||
NativeToken: chain.NativeToken,
|
||||
TokenConfigs: contracts,
|
||||
Name: chain.Name,
|
||||
RPCURL: rpcURL,
|
||||
ChainID: chain.ChainID,
|
||||
NativeToken: chain.NativeToken,
|
||||
TokenConfigs: contracts,
|
||||
GasTopUpPolicy: gasPolicy,
|
||||
})
|
||||
}
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !defaultSet {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
|
||||
}
|
||||
|
||||
policy := &gatewayshared.GasTopUpPolicy{
|
||||
Default: defaultRule,
|
||||
}
|
||||
|
||||
if cfg.Native != nil {
|
||||
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set {
|
||||
policy.Native = &rule
|
||||
}
|
||||
}
|
||||
if cfg.Contract != nil {
|
||||
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set {
|
||||
policy.Contract = &rule
|
||||
}
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
|
||||
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
|
||||
return gatewayshared.GasTopUpRule{}, false, nil
|
||||
}
|
||||
if cfg.BufferPercent < 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
|
||||
}
|
||||
if cfg.MinNativeBalanceTRX < 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
|
||||
}
|
||||
if cfg.RoundingUnitTRX <= 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
|
||||
}
|
||||
if cfg.MaxTopUpTRX <= 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
|
||||
}
|
||||
return gatewayshared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
|
||||
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
|
||||
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
|
||||
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||
@@ -215,13 +322,13 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
|
||||
|
||||
if address == "" {
|
||||
if cfg.AddressEnv != "" {
|
||||
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
||||
logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
||||
} else {
|
||||
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain))
|
||||
logger.Warn("Service wallet address not configured", zap.String("chain", cfg.Chain))
|
||||
}
|
||||
}
|
||||
if privateKey == "" {
|
||||
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||
logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||
}
|
||||
|
||||
return gatewayshared.ServiceWallet{
|
||||
@@ -235,7 +342,7 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
|
||||
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")
|
||||
logger.Error("Key management driver missing")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -244,19 +351,19 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
logger.Error("Unsupported key management driver", zap.String("driver", driver))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ type Registry struct {
|
||||
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
||||
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
||||
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
||||
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
|
||||
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
|
||||
}
|
||||
|
||||
type RegistryDeps struct {
|
||||
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
|
||||
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
||||
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
||||
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
||||
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
|
||||
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
@@ -11,9 +14,11 @@ import (
|
||||
|
||||
type Deps struct {
|
||||
Logger mlogger.Logger
|
||||
Networks map[string]shared.Network
|
||||
Drivers *drivers.Registry
|
||||
Networks *rpcclient.Registry
|
||||
Storage storage.Repository
|
||||
Clock clockpkg.Clock
|
||||
RPCTimeout time.Duration
|
||||
EnsureRepository func(context.Context) error
|
||||
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
||||
}
|
||||
|
||||
@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||
if managedRef != "" && external != "" {
|
||||
deps.Logger.Warn("both managed and external destination provided")
|
||||
deps.Logger.Warn("Both managed and external destination provided")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||
}
|
||||
if managedRef != "" {
|
||||
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||
}
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
@@ -40,11 +40,26 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
||||
}, nil
|
||||
}
|
||||
if external == "" {
|
||||
deps.Logger.Warn("destination external address missing")
|
||||
deps.Logger.Warn("Destination external address missing")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
if deps.Drivers == nil {
|
||||
deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network))
|
||||
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||
}
|
||||
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||
}
|
||||
normalized, err := chainDriver.NormalizeAddress(external)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("Invalid external address", zap.Error(err))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
return model.TransferDestination{
|
||||
ExternalAddress: strings.ToLower(external),
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
ExternalAddress: normalized,
|
||||
ExternalAddressOriginal: external,
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
|
||||
func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
||||
if err != nil {
|
||||
@@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
return "", merrors.Internal("destination wallet missing deposit address")
|
||||
}
|
||||
return wallet.DepositAddress, nil
|
||||
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||
}
|
||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||
return strings.ToLower(addr), nil
|
||||
return chainDriver.NormalizeAddress(addr)
|
||||
}
|
||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||
}
|
||||
|
||||
@@ -3,22 +3,13 @@ package transfer
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -33,216 +24,94 @@ func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
|
||||
|
||||
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Empty request received")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("source wallet ref missing")
|
||||
c.deps.Logger.Warn("Source wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
c.deps.Logger.Warn("amount missing or incomplete")
|
||||
c.deps.Logger.Warn("Amount missing or incomplete")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks[networkKey]
|
||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
if c.deps.Drivers == nil {
|
||||
c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey))
|
||||
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||
}
|
||||
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
|
||||
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
||||
c.deps.Logger.Warn("Failed to resolve destination address", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
|
||||
walletForFee := sourceWallet
|
||||
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
|
||||
copyWallet := *sourceWallet
|
||||
copyWallet.ContractAddress = ""
|
||||
copyWallet.TokenSymbol = nativeCurrency
|
||||
walletForFee = ©Wallet
|
||||
}
|
||||
|
||||
driverDeps := driver.Deps{
|
||||
Logger: c.deps.Logger,
|
||||
Registry: c.deps.Networks,
|
||||
RPCTimeout: c.deps.RPCTimeout,
|
||||
}
|
||||
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Fee estimation failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
contextLabel := "erc20_transfer"
|
||||
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
|
||||
contextLabel = "native_transfer"
|
||||
}
|
||||
resp := &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: feeMoney,
|
||||
EstimationContext: "erc20_transfer",
|
||||
EstimationContext: contextLabel,
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
||||
}
|
||||
if strings.TrimSpace(wallet.ContractAddress) == "" {
|
||||
return nil, merrors.NotImplemented("native token transfers not supported")
|
||||
}
|
||||
if !common.IsHexAddress(wallet.ContractAddress) {
|
||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||
}
|
||||
if !common.IsHexAddress(wallet.DepositAddress) {
|
||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
||||
}
|
||||
if !common.IsHexAddress(destination) {
|
||||
return nil, merrors.InvalidArgument("invalid destination address")
|
||||
}
|
||||
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
||||
}
|
||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
||||
toAddr := common.HexToAddress(destination)
|
||||
fromAddr := common.HexToAddress(wallet.DepositAddress)
|
||||
|
||||
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
|
||||
if err != nil {
|
||||
logger.Warn("failed to read token decimals", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||
}
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: fromAddr,
|
||||
To: &tokenAddr,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
|
||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(network.Name)
|
||||
}
|
||||
|
||||
return &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: feeDec.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
||||
callData, err := tokenABI.Pack("decimals")
|
||||
if err != nil {
|
||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
||||
}
|
||||
msg := ethereum.CallMsg{
|
||||
To: &token,
|
||||
Data: callData,
|
||||
}
|
||||
output, err := client.CallContract(ctx, msg, nil)
|
||||
if err != nil {
|
||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
||||
}
|
||||
values, err := tokenABI.Unpack("decimals", output)
|
||||
if err != nil {
|
||||
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, merrors.Internal("decimals call returned no data")
|
||||
}
|
||||
decimals, ok := values[0].(uint8)
|
||||
if !ok {
|
||||
return 0, merrors.Internal("decimals call returned unexpected type")
|
||||
}
|
||||
return decimals, nil
|
||||
}
|
||||
|
||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
||||
}
|
||||
if value.IsNegative() {
|
||||
return nil, merrors.InvalidArgument("amount must be positive")
|
||||
}
|
||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
||||
scaled := value.Mul(multiplier)
|
||||
if !scaled.Equal(scaled.Truncate(0)) {
|
||||
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
||||
}
|
||||
return scaled.BigInt(), nil
|
||||
}
|
||||
|
||||
const erc20TransferABI = `
|
||||
[
|
||||
{
|
||||
"constant": true,
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [{ "name": "", "type": "uint8" }],
|
||||
"payable": false,
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"constant": false,
|
||||
"inputs": [
|
||||
{ "name": "_to", "type": "address" },
|
||||
{ "name": "_value", "type": "uint256" }
|
||||
],
|
||||
"name": "transfer",
|
||||
"outputs": [{ "name": "", "type": "bool" }],
|
||||
"payable": false,
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]`
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type computeGasTopUpCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
|
||||
return &computeGasTopUpCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("Wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
estimatedFee := req.GetEstimatedTotalFee()
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
c.deps.Logger.Warn("Estimated fee missing")
|
||||
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||
}
|
||||
|
||||
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||
|
||||
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
|
||||
TopupAmount: topUp,
|
||||
CapHit: capHit,
|
||||
})
|
||||
}
|
||||
|
||||
type ensureGasTopUpCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
|
||||
return &ensureGasTopUpCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("Idempotency key missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("Organization ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("Source wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
|
||||
if targetWalletRef == "" {
|
||||
c.deps.Logger.Warn("Target wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
|
||||
}
|
||||
estimatedFee := req.GetEstimatedTotalFee()
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
c.deps.Logger.Warn("Estimated fee missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||
}
|
||||
|
||||
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||
|
||||
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
|
||||
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
|
||||
TopupAmount: nil,
|
||||
CapHit: capHit,
|
||||
})
|
||||
}
|
||||
|
||||
submitReq := &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OrganizationRef: organizationRef,
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
|
||||
},
|
||||
Amount: topUp,
|
||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||
}
|
||||
|
||||
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
|
||||
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
submitResp, err := submitResponder(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &chainv1.EnsureGasTopUpResponse{
|
||||
TopupAmount: topUp,
|
||||
CapHit: capHit,
|
||||
Transfer: submitResp.GetTransfer(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
estimatedFee = shared.CloneMoney(estimatedFee)
|
||||
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
|
||||
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
|
||||
networkCfg, ok := deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
|
||||
}
|
||||
|
||||
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(networkKey, "tron") {
|
||||
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
|
||||
}
|
||||
|
||||
if networkCfg.GasTopUpPolicy != nil {
|
||||
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
return topUp, capHit, nil, nativeBalance, walletModel, nil
|
||||
}
|
||||
|
||||
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
return topUp, false, nil, nativeBalance, walletModel, nil
|
||||
}
|
||||
|
||||
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
if walletModel == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
walletDeps := wallet.Deps{
|
||||
Logger: deps.Logger.Named("wallet"),
|
||||
Drivers: deps.Drivers,
|
||||
Networks: deps.Networks,
|
||||
KeyManager: nil,
|
||||
Storage: deps.Storage,
|
||||
Clock: deps.Clock,
|
||||
BalanceCacheTTL: 0,
|
||||
RPCTimeout: deps.RPCTimeout,
|
||||
EnsureRepository: deps.EnsureRepository,
|
||||
}
|
||||
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("native balance is unavailable")
|
||||
}
|
||||
return nativeBalance, nil
|
||||
}
|
||||
|
||||
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("estimated fee is required")
|
||||
}
|
||||
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("native balance is required")
|
||||
}
|
||||
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
|
||||
return nil, merrors.InvalidArgument("native balance currency mismatch")
|
||||
}
|
||||
|
||||
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
required := estimated.Sub(current)
|
||||
if !required.IsPositive() {
|
||||
return nil, nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
|
||||
Amount: required.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("estimated_total_fee", amountString(estimatedFee)),
|
||||
zap.String("current_native_balance", amountString(nativeBalance)),
|
||||
zap.String("topup_amount", amountString(topUp)),
|
||||
zap.Bool("cap_hit", capHit),
|
||||
}
|
||||
if walletModel != nil {
|
||||
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
|
||||
}
|
||||
if decision != nil {
|
||||
fields = append(fields,
|
||||
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
|
||||
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
|
||||
zap.String("required_trx", decision.RequiredTRX.String()),
|
||||
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
|
||||
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
|
||||
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
|
||||
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
|
||||
zap.String("topup_trx", decision.TopUpTRX.String()),
|
||||
zap.String("operation_type", decision.OperationType),
|
||||
)
|
||||
}
|
||||
logger.Info("Gas top-up decision", fields...)
|
||||
}
|
||||
|
||||
func amountString(m *moneyv1.Money) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
amount := strings.TrimSpace(m.GetAmount())
|
||||
currency := strings.TrimSpace(m.GetCurrency())
|
||||
if amount == "" && currency == "" {
|
||||
return ""
|
||||
}
|
||||
if currency == "" {
|
||||
return amount
|
||||
}
|
||||
if amount == "" {
|
||||
return currency
|
||||
}
|
||||
return amount + " " + currency
|
||||
}
|
||||
@@ -22,25 +22,25 @@ func NewGetTransfer(deps Deps) *getTransferCommand {
|
||||
|
||||
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||
if transferRef == "" {
|
||||
c.deps.Logger.Warn("transfer_ref missing")
|
||||
c.deps.Logger.Warn("Transfer_ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
||||
}
|
||||
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef))
|
||||
c.deps.Logger.Warn("Not found", zap.String("transfer_ref", transferRef))
|
||||
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
||||
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
||||
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewListTransfers(deps Deps) *listTransfersCommand {
|
||||
|
||||
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.TransferFilter{}
|
||||
@@ -41,7 +41,7 @@ func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTra
|
||||
|
||||
result, err := c.deps.Storage.Transfers().List(ctx, filter)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Storage list failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,94 +25,102 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
|
||||
|
||||
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
c.deps.Logger.Warn("Missing idempotency key")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
c.deps.Logger.Warn("Missing organization ref")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("missing source wallet ref")
|
||||
c.deps.Logger.Warn("Missing source wallet ref")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil {
|
||||
c.deps.Logger.Warn("missing amount")
|
||||
c.deps.Logger.Warn("Missing amount")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
if amountCurrency == "" {
|
||||
c.deps.Logger.Warn("missing amount currency")
|
||||
c.deps.Logger.Warn("Missing amount currency")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||
}
|
||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||
if amountValue == "" {
|
||||
c.deps.Logger.Warn("missing amount value")
|
||||
c.deps.Logger.Warn("Missing amount value")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||
}
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks[networkKey]
|
||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Fee conversion failed", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
amountDec, err := decimal.NewFromString(amountValue)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("invalid amount", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid amount", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||
}
|
||||
netDec := amountDec.Sub(feeSum)
|
||||
if netDec.IsNegative() {
|
||||
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||
}
|
||||
|
||||
netAmount := shared.CloneMoney(amount)
|
||||
netAmount.Amount = netDec.String()
|
||||
|
||||
effectiveTokenSymbol := sourceWallet.TokenSymbol
|
||||
effectiveContractAddress := sourceWallet.ContractAddress
|
||||
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
|
||||
effectiveTokenSymbol = nativeCurrency
|
||||
effectiveContractAddress = ""
|
||||
}
|
||||
|
||||
transfer := &model.Transfer{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TransferRef: shared.GenerateTransferRef(),
|
||||
@@ -120,8 +128,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Network: sourceWallet.Network,
|
||||
TokenSymbol: sourceWallet.TokenSymbol,
|
||||
ContractAddress: sourceWallet.ContractAddress,
|
||||
TokenSymbol: effectiveTokenSymbol,
|
||||
ContractAddress: effectiveContractAddress,
|
||||
RequestedAmount: shared.CloneMoney(amount),
|
||||
NetAmount: netAmount,
|
||||
Fees: fees,
|
||||
@@ -133,10 +141,10 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,41 +29,41 @@ func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
|
||||
|
||||
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("wallet_ref missing")
|
||||
c.deps.Logger.Warn("Wallet_ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
||||
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
|
||||
if chainErr != nil {
|
||||
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("On-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Cached balance not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||
}
|
||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if c.isCachedBalanceStale(stored) {
|
||||
c.deps.Logger.Warn("cached balance is stale",
|
||||
c.deps.Logger.Info("Cached balance is stale",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Time("calculated_at", stored.CalculatedAt),
|
||||
zap.Duration("ttl", c.cacheTTL()),
|
||||
@@ -74,39 +74,49 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
||||
}
|
||||
|
||||
calculatedAt := c.now()
|
||||
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
||||
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
|
||||
|
||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||
Balance: onChainBalanceToProto(balance, calculatedAt),
|
||||
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||
if balance == nil {
|
||||
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||
if balance == nil && native == nil {
|
||||
return nil
|
||||
}
|
||||
zero := zeroMoney(balance.Currency)
|
||||
currency := ""
|
||||
if balance != nil {
|
||||
currency = balance.Currency
|
||||
}
|
||||
zero := zeroMoney(currency)
|
||||
return &chainv1.WalletBalance{
|
||||
Available: balance,
|
||||
NativeAvailable: native,
|
||||
PendingInbound: zero,
|
||||
PendingOutbound: zero,
|
||||
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
||||
if available == nil {
|
||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
|
||||
if available == nil && nativeAvailable == nil {
|
||||
return
|
||||
}
|
||||
record := &model.WalletBalance{
|
||||
WalletRef: walletRef,
|
||||
Available: shared.CloneMoney(available),
|
||||
PendingInbound: zeroMoney(available.Currency),
|
||||
PendingOutbound: zeroMoney(available.Currency),
|
||||
NativeAvailable: shared.CloneMoney(nativeAvailable),
|
||||
CalculatedAt: calculatedAt,
|
||||
}
|
||||
currency := ""
|
||||
if available != nil {
|
||||
currency = available.Currency
|
||||
}
|
||||
record.PendingInbound = zeroMoney(currency)
|
||||
record.PendingOutbound = zeroMoney(currency)
|
||||
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
||||
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
c.deps.Logger.Warn("Failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
@@ -24,78 +25,118 @@ func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
|
||||
|
||||
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
c.deps.Logger.Warn("Missing idempotency key")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
c.deps.Logger.Warn("Missing organization ref")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||
if ownerRef == "" {
|
||||
c.deps.Logger.Warn("missing owner ref")
|
||||
c.deps.Logger.Warn("Missing owner ref")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||
}
|
||||
|
||||
asset := req.GetAsset()
|
||||
if asset == nil {
|
||||
c.deps.Logger.Warn("missing asset")
|
||||
c.deps.Logger.Warn("Missing asset")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||
}
|
||||
|
||||
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
|
||||
if chainKey == "" {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
networkCfg, ok := c.deps.Networks[chainKey]
|
||||
networkCfg, ok := c.deps.Networks.Network(chainKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
||||
c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
if c.deps.Drivers == nil {
|
||||
c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey))
|
||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||
}
|
||||
chainDriver, err := c.deps.Drivers.Driver(chainKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
|
||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
if tokenSymbol == "" {
|
||||
c.deps.Logger.Warn("missing token symbol")
|
||||
c.deps.Logger.Warn("Missing token symbol")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||
}
|
||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||
if contractAddress == "" {
|
||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
|
||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walletRef := shared.GenerateWalletRef()
|
||||
if c.deps.KeyManager == nil {
|
||||
c.deps.Logger.Warn("key manager missing")
|
||||
c.deps.Logger.Warn("Key manager missing")
|
||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||
}
|
||||
|
||||
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("key manager error", zap.Error(err))
|
||||
c.deps.Logger.Warn("Key manager error", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||
c.deps.Logger.Warn("key manager returned empty address")
|
||||
c.deps.Logger.Warn("Key manager returned empty address")
|
||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||
}
|
||||
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("Invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
metadata := shared.CloneMetadata(req.GetMetadata())
|
||||
desc := req.GetDescribable()
|
||||
name := strings.TrimSpace(desc.GetName())
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(metadata["name"])
|
||||
}
|
||||
var description *string
|
||||
if desc != nil && desc.Description != nil {
|
||||
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if description == nil {
|
||||
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
|
||||
description = &trimmed
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = walletRef
|
||||
}
|
||||
|
||||
wallet := &model.ManagedWallet{
|
||||
Describable: pkgmodel.Describable{
|
||||
Name: name,
|
||||
},
|
||||
IdempotencyKey: idempotencyKey,
|
||||
WalletRef: walletRef,
|
||||
OrganizationRef: organizationRef,
|
||||
@@ -103,19 +144,22 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
||||
Network: chainKey,
|
||||
TokenSymbol: tokenSymbol,
|
||||
ContractAddress: contractAddress,
|
||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
||||
DepositAddress: depositAddress,
|
||||
KeyReference: keyInfo.KeyID,
|
||||
Status: model.ManagedWalletStatusActive,
|
||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||
Metadata: metadata,
|
||||
}
|
||||
if description != nil {
|
||||
wallet.Describable.Description = description
|
||||
}
|
||||
|
||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
c.deps.Logger.Debug("Wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
"github.com/tech/sendico/gateway/chain/storage"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -13,17 +14,20 @@ import (
|
||||
|
||||
type Deps struct {
|
||||
Logger mlogger.Logger
|
||||
Networks map[string]shared.Network
|
||||
Drivers *drivers.Registry
|
||||
Networks *rpcclient.Registry
|
||||
KeyManager keymanager.Manager
|
||||
Storage storage.Repository
|
||||
Clock clockpkg.Clock
|
||||
BalanceCacheTTL time.Duration
|
||||
RPCTimeout time.Duration
|
||||
EnsureRepository func(context.Context) error
|
||||
}
|
||||
|
||||
func (d Deps) WithLogger(name string) Deps {
|
||||
if d.Logger != nil {
|
||||
d.Logger = d.Logger.Named(name)
|
||||
if d.Logger == nil {
|
||||
panic("wallet deps: logger is required")
|
||||
}
|
||||
d.Logger = d.Logger.Named(name)
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -22,25 +22,25 @@ func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
|
||||
|
||||
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("wallet_ref missing")
|
||||
c.deps.Logger.Warn("Wallet_ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
|
||||
|
||||
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.ManagedWalletFilter{}
|
||||
@@ -42,7 +42,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li
|
||||
|
||||
result, err := c.deps.Storage.Wallets().List(ctx, filter)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Storage list failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,123 +2,61 @@ package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
return nil, merrors.Internal("network rpc url is not configured")
|
||||
func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
if wallet == nil {
|
||||
return nil, nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||
if contract == "" || !common.IsHexAddress(contract) {
|
||||
return nil, merrors.InvalidArgument("invalid contract address")
|
||||
if deps.Networks == nil {
|
||||
return nil, nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
|
||||
return nil, merrors.InvalidArgument("invalid wallet address")
|
||||
if deps.Drivers == nil {
|
||||
return nil, nil, merrors.Internal("chain drivers not configured")
|
||||
}
|
||||
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
||||
}
|
||||
tokenAddr := common.HexToAddress(contract)
|
||||
walletAddr := common.HexToAddress(wallet.DepositAddress)
|
||||
|
||||
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
||||
}
|
||||
|
||||
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
||||
data, err := tokenABI.Pack("decimals")
|
||||
if err != nil {
|
||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
||||
}
|
||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
||||
out, err := client.CallContract(ctx, msg, nil)
|
||||
if err != nil {
|
||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
||||
}
|
||||
values, err := tokenABI.Unpack("decimals", out)
|
||||
if err != nil || len(values) == 0 {
|
||||
return 0, merrors.Internal("failed to unpack decimals")
|
||||
}
|
||||
if val, ok := values[0].(uint8); ok {
|
||||
return val, nil
|
||||
}
|
||||
return 0, merrors.Internal("decimals returned unexpected type")
|
||||
}
|
||||
|
||||
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
|
||||
data, err := tokenABI.Pack("balanceOf", wallet)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
|
||||
}
|
||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
||||
out, err := client.CallContract(ctx, msg, nil)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
||||
}
|
||||
values, err := tokenABI.Unpack("balanceOf", out)
|
||||
if err != nil || len(values) == 0 {
|
||||
return nil, merrors.Internal("failed to unpack balanceOf")
|
||||
}
|
||||
raw, ok := values[0].(*big.Int)
|
||||
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
||||
network, ok := deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("balanceOf returned unexpected type")
|
||||
logger.Warn("Requested network is not configured",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", networkKey),
|
||||
)
|
||||
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||
}
|
||||
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
|
||||
}
|
||||
|
||||
const erc20ABIJSON = `
|
||||
[
|
||||
{
|
||||
"constant": true,
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [{ "name": "", "type": "uint8" }],
|
||||
"payable": false,
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"constant": true,
|
||||
"inputs": [{ "name": "_owner", "type": "address" }],
|
||||
"name": "balanceOf",
|
||||
"outputs": [{ "name": "balance", "type": "uint256" }],
|
||||
"payable": false,
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
chainDriver, err := deps.Drivers.Driver(networkKey)
|
||||
if err != nil {
|
||||
logger.Warn("Chain driver not configured",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", networkKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, merrors.InvalidArgument("unsupported chain")
|
||||
}
|
||||
]`
|
||||
|
||||
driverDeps := driver.Deps{
|
||||
Logger: deps.Logger,
|
||||
Registry: deps.Networks,
|
||||
KeyManager: deps.KeyManager,
|
||||
RPCTimeout: deps.RPCTimeout,
|
||||
}
|
||||
tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return tokenBalance, nativeBalance, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
||||
TokenSymbol: wallet.TokenSymbol,
|
||||
ContractAddress: wallet.ContractAddress,
|
||||
}
|
||||
name := strings.TrimSpace(wallet.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(wallet.Metadata["name"])
|
||||
}
|
||||
if name == "" {
|
||||
name = wallet.WalletRef
|
||||
}
|
||||
description := ""
|
||||
switch {
|
||||
case wallet.Description != nil:
|
||||
description = strings.TrimSpace(*wallet.Description)
|
||||
default:
|
||||
description = strings.TrimSpace(wallet.Metadata["description"])
|
||||
}
|
||||
desc := &describablev1.Describable{Name: name}
|
||||
if description != "" {
|
||||
desc.Description = &description
|
||||
}
|
||||
|
||||
return &chainv1.ManagedWallet{
|
||||
WalletRef: wallet.WalletRef,
|
||||
OrganizationRef: wallet.OrganizationRef,
|
||||
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
||||
Metadata: shared.CloneMetadata(wallet.Metadata),
|
||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||
Describable: desc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
|
||||
}
|
||||
return &chainv1.WalletBalance{
|
||||
Available: shared.CloneMoney(balance.Available),
|
||||
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
|
||||
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
||||
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package arbitrum
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Driver implements Arbitrum-specific behavior using the shared EVM logic.
|
||||
type Driver struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger) *Driver {
|
||||
return &Driver{logger: logger.Named("arbitrum")}
|
||||
}
|
||||
|
||||
func (d *Driver) Name() string {
|
||||
return "arbitrum"
|
||||
}
|
||||
|
||||
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||
d.logger.Debug("Format address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Native balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Native balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("Estimate fee failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||
d.logger.Debug("Submit transfer request",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||
if err != nil {
|
||||
d.logger.Warn("Submit transfer failed",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
d.logger.Debug("Submit transfer result",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("tx_hash", txHash),
|
||||
)
|
||||
}
|
||||
return txHash, err
|
||||
}
|
||||
|
||||
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
d.logger.Debug("Await confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||
if err != nil {
|
||||
d.logger.Warn("Await confirmation failed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if receipt != nil {
|
||||
d.logger.Debug("Await confirmation result",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
zap.Uint64("status", receipt.Status),
|
||||
)
|
||||
}
|
||||
return receipt, err
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package driver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
// Deps bundles dependencies shared across chain drivers.
|
||||
type Deps struct {
|
||||
Logger mlogger.Logger
|
||||
Registry *rpcclient.Registry
|
||||
KeyManager keymanager.Manager
|
||||
RPCTimeout time.Duration
|
||||
}
|
||||
|
||||
// Driver defines chain-specific behavior for wallet and transfer operations.
|
||||
type Driver interface {
|
||||
Name() string
|
||||
FormatAddress(address string) (string, error)
|
||||
NormalizeAddress(address string) (string, error)
|
||||
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
|
||||
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
|
||||
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package ethereum
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Driver implements Ethereum-specific behavior using the shared EVM logic.
|
||||
type Driver struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger) *Driver {
|
||||
return &Driver{logger: logger.Named("ethereum")}
|
||||
}
|
||||
|
||||
func (d *Driver) Name() string {
|
||||
return "ethereum"
|
||||
}
|
||||
|
||||
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||
d.logger.Debug("Format address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Native balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Native balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("Estimate fee failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||
d.logger.Debug("Submit transfer request",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||
if err != nil {
|
||||
d.logger.Warn("Submit transfer failed",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
d.logger.Debug("Submit transfer result",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("tx_hash", txHash),
|
||||
)
|
||||
}
|
||||
return txHash, err
|
||||
}
|
||||
|
||||
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
d.logger.Debug("Await confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||
if err != nil {
|
||||
d.logger.Warn("Await confirmation failed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if receipt != nil {
|
||||
d.logger.Debug("Await confirmation result",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
zap.Uint64("status", receipt.Status),
|
||||
)
|
||||
}
|
||||
return receipt, err
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
@@ -0,0 +1,31 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTronEstimateCallUsesData(t *testing.T) {
|
||||
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
|
||||
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: from,
|
||||
To: &to,
|
||||
GasPrice: big.NewInt(100),
|
||||
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
|
||||
}
|
||||
|
||||
call := tronEstimateCall(callMsg)
|
||||
|
||||
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
|
||||
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
|
||||
require.Equal(t, "0x64", call["gasPrice"])
|
||||
require.Equal(t, "0xa9059cbb", call["data"])
|
||||
_, hasInput := call["input"]
|
||||
require.False(t, hasInput)
|
||||
}
|
||||
747
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
747
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/rpc"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
erc20ABI abi.ABI
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
|
||||
if err != nil {
|
||||
panic("evm driver: failed to parse erc20 abi: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
const erc20ABIJSON = `
|
||||
[
|
||||
{
|
||||
"constant": false,
|
||||
"inputs": [
|
||||
{ "name": "_to", "type": "address" },
|
||||
{ "name": "_value", "type": "uint256" }
|
||||
],
|
||||
"name": "transfer",
|
||||
"outputs": [{ "name": "", "type": "bool" }],
|
||||
"payable": false,
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"constant": true,
|
||||
"inputs": [],
|
||||
"name": "decimals",
|
||||
"outputs": [{ "name": "", "type": "uint8" }],
|
||||
"payable": false,
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]`
|
||||
|
||||
// NormalizeAddress validates and normalizes EVM hex addresses.
|
||||
func NormalizeAddress(address string) (string, error) {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if trimmed == "" {
|
||||
return "", merrors.InvalidArgument("address is required")
|
||||
}
|
||||
if !common.IsHexAddress(trimmed) {
|
||||
return "", merrors.InvalidArgument("invalid hex address")
|
||||
}
|
||||
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
||||
}
|
||||
|
||||
func nativeCurrency(network shared.Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(network.Name)
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
func parseBaseUnitAmount(amount string) (*big.Int, error) {
|
||||
trimmed := strings.TrimSpace(amount)
|
||||
if trimmed == "" {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
value, ok := new(big.Int).SetString(trimmed, 10)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("invalid amount")
|
||||
}
|
||||
if value.Sign() < 0 {
|
||||
return nil, merrors.InvalidArgument("amount must be non-negative")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Balance fetches ERC20 token balance for the provided address.
|
||||
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
|
||||
normalizedAddress, err := NormalizeAddress(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
logFields := []zap.Field{
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
|
||||
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
|
||||
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
|
||||
zap.String("wallet_address", normalizedAddress),
|
||||
}
|
||||
if rpcURL == "" {
|
||||
logger.Warn("Network rpc url is not configured", logFields...)
|
||||
return nil, merrors.Internal("network rpc url is not configured")
|
||||
}
|
||||
|
||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||
if contract == "" {
|
||||
logger.Debug("Native balance requested", logFields...)
|
||||
return NativeBalance(ctx, deps, network, wallet, normalizedAddress)
|
||||
}
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Invalid contract address for balance fetch", logFields...)
|
||||
return nil, merrors.InvalidArgument("invalid contract address")
|
||||
}
|
||||
|
||||
logger.Info("Fetching on-chain wallet balance", logFields...)
|
||||
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := deps.RPCTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
logger.Debug("Calling token decimals", logFields...)
|
||||
decimals, err := readDecimals(timeoutCtx, rpcClient, contract)
|
||||
if err != nil {
|
||||
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
|
||||
bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, normalizedAddress)
|
||||
if err != nil {
|
||||
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
||||
logger.Info("On-chain wallet balance fetched",
|
||||
append(logFields,
|
||||
zap.Uint8("decimals", decimals),
|
||||
zap.String("balance_raw", bal.String()),
|
||||
zap.String("balance", dec.String()),
|
||||
)...,
|
||||
)
|
||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
||||
}
|
||||
|
||||
// NativeBalance fetches native token balance for the provided address.
|
||||
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
|
||||
normalizedAddress, err := NormalizeAddress(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
logFields := []zap.Field{
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
|
||||
zap.String("wallet_address", normalizedAddress),
|
||||
}
|
||||
if rpcURL == "" {
|
||||
logger.Warn("Network rpc url is not configured", logFields...)
|
||||
return nil, merrors.Internal("network rpc url is not configured")
|
||||
}
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := deps.RPCTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil)
|
||||
if err != nil {
|
||||
logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("On-chain native balance fetched",
|
||||
append(logFields,
|
||||
zap.String("balance_raw", bal.String()),
|
||||
)...,
|
||||
)
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: bal.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EstimateFee estimates ERC20 transfer fees for the given parameters.
|
||||
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if amount == nil {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
||||
}
|
||||
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
||||
}
|
||||
if _, err := NormalizeAddress(destination); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid destination address")
|
||||
}
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name))
|
||||
return nil, err
|
||||
}
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := deps.RPCTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||
toAddr := common.HexToAddress(destination)
|
||||
fromAddr := common.HexToAddress(fromAddress)
|
||||
|
||||
if contract == "" {
|
||||
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
||||
if err != nil {
|
||||
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
|
||||
return nil, err
|
||||
}
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: fromAddr,
|
||||
To: &toAddr,
|
||||
GasPrice: gasPrice,
|
||||
Value: amountBase,
|
||||
}
|
||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg))
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: feeDec.String(),
|
||||
}, nil
|
||||
}
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Failed to validate contract", zap.String("contract", contract))
|
||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||
}
|
||||
|
||||
tokenAddr := common.HexToAddress(contract)
|
||||
|
||||
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to read token decimals", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to encode transfer call", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||
}
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: fromAddr,
|
||||
To: &tokenAddr,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
|
||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: feeDec.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
|
||||
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if deps.KeyManager == nil {
|
||||
logger.Warn("Key manager not configured")
|
||||
return "", executorInternal("key manager is not configured", nil)
|
||||
}
|
||||
if registry == nil {
|
||||
return "", executorInternal("rpc clients not initialised", nil)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
||||
return "", executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
if source == nil || transfer == nil {
|
||||
logger.Warn("Transfer context missing")
|
||||
return "", executorInvalid("transfer context missing")
|
||||
}
|
||||
if strings.TrimSpace(source.KeyReference) == "" {
|
||||
logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||
return "", executorInvalid("source wallet missing key reference")
|
||||
}
|
||||
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||
logger.Warn("Invalid source wallet address", zap.String("wallet_ref", source.WalletRef))
|
||||
return "", executorInvalid("invalid source wallet address")
|
||||
}
|
||||
if _, err := NormalizeAddress(destination); err != nil {
|
||||
logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destination))
|
||||
return "", executorInvalid("invalid destination address " + destination)
|
||||
}
|
||||
|
||||
logger.Info("Submitting transfer",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("source_wallet_ref", source.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", strings.ToLower(destination)),
|
||||
)
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
|
||||
sourceAddress := common.HexToAddress(fromAddress)
|
||||
destinationAddr := common.HexToAddress(destination)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
)
|
||||
return "", executorInternal("failed to fetch nonce", err)
|
||||
}
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
return "", executorInternal("failed to suggest gas price", err)
|
||||
}
|
||||
|
||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||
|
||||
contract := strings.TrimSpace(transfer.ContractAddress)
|
||||
amount := transfer.NetAmount
|
||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", executorInvalid("transfer missing net amount")
|
||||
}
|
||||
|
||||
var tx *types.Transaction
|
||||
if contract == "" {
|
||||
amountInt, err := parseBaseUnitAmount(amount.Amount)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", err
|
||||
}
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: sourceAddress,
|
||||
To: &destinationAddr,
|
||||
GasPrice: gasPrice,
|
||||
Value: amountInt,
|
||||
}
|
||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to estimate gas", err)
|
||||
}
|
||||
tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil)
|
||||
} else {
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", contract),
|
||||
)
|
||||
return "", executorInvalid("invalid token contract address " + contract)
|
||||
}
|
||||
tokenAddress := common.HexToAddress(contract)
|
||||
|
||||
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", contract),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("amount", amount.Amount),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to encode transfer call", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to encode transfer call", err)
|
||||
}
|
||||
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: sourceAddress,
|
||||
To: &tokenAddress,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to estimate gas", err)
|
||||
}
|
||||
|
||||
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||
}
|
||||
|
||||
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||
logger.Warn("Failed to send transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to send transaction", err)
|
||||
}
|
||||
|
||||
txHash := signedTx.Hash().Hex()
|
||||
logger.Info("Transaction submitted",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
|
||||
return txHash, nil
|
||||
}
|
||||
|
||||
// AwaitConfirmation waits for the transaction receipt.
|
||||
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
return nil, executorInvalid("tx hash is required")
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
logger.Warn("Network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
return nil, executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
if registry == nil {
|
||||
return nil, executorInternal("rpc clients not initialised", nil)
|
||||
}
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash := common.HexToHash(txHash)
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
receipt, err := client.TransactionReceipt(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, ethereum.NotFound) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logger.Debug("Transaction not yet mined",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
logger.Warn("Context cancelled while awaiting confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
logger.Warn("Failed to fetch transaction receipt",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||
}
|
||||
logger.Info("Transaction confirmed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
zap.Uint64("status", receipt.Status),
|
||||
)
|
||||
return receipt, nil
|
||||
}
|
||||
}
|
||||
|
||||
func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) {
|
||||
call := map[string]string{
|
||||
"to": strings.ToLower(common.HexToAddress(token).Hex()),
|
||||
"data": "0x313ce567",
|
||||
}
|
||||
var hexResp string
|
||||
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
||||
}
|
||||
val, err := shared.DecodeHexUint8(hexResp)
|
||||
if err != nil {
|
||||
return 0, merrors.Internal("decimals decode failed: " + err.Error())
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) {
|
||||
tokenAddr := common.HexToAddress(token)
|
||||
walletAddr := common.HexToAddress(wallet)
|
||||
addr := strings.TrimPrefix(walletAddr.Hex(), "0x")
|
||||
if len(addr) < 64 {
|
||||
addr = strings.Repeat("0", 64-len(addr)) + addr
|
||||
}
|
||||
call := map[string]string{
|
||||
"to": strings.ToLower(tokenAddr.Hex()),
|
||||
"data": "0x70a08231" + addr,
|
||||
}
|
||||
var hexResp string
|
||||
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
||||
}
|
||||
bigVal, err := shared.DecodeHexBig(hexResp)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("balanceOf decode failed: " + err.Error())
|
||||
}
|
||||
return bigVal, nil
|
||||
}
|
||||
|
||||
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||
call := map[string]string{
|
||||
"to": strings.ToLower(token.Hex()),
|
||||
"data": "0x313ce567",
|
||||
}
|
||||
var hexResp string
|
||||
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||
return 0, executorInternal("decimals call failed", err)
|
||||
}
|
||||
val, err := shared.DecodeHexUint8(hexResp)
|
||||
if err != nil {
|
||||
return 0, executorInternal("decimals decode failed", err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
type gasEstimator interface {
|
||||
EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error)
|
||||
}
|
||||
|
||||
func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||
if isTronNetwork(network) {
|
||||
if rpcClient == nil {
|
||||
return 0, merrors.Internal("rpc client not initialised")
|
||||
}
|
||||
return estimateGasTron(ctx, rpcClient, callMsg)
|
||||
}
|
||||
return client.EstimateGas(ctx, callMsg)
|
||||
}
|
||||
|
||||
func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||
call := tronEstimateCall(callMsg)
|
||||
var hexResp string
|
||||
if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
val, err := shared.DecodeHexBig(hexResp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if val == nil {
|
||||
return 0, merrors.Internal("failed to decode gas estimate")
|
||||
}
|
||||
return val.Uint64(), nil
|
||||
}
|
||||
|
||||
func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
|
||||
call := make(map[string]string)
|
||||
if callMsg.From != (common.Address{}) {
|
||||
call["from"] = strings.ToLower(callMsg.From.Hex())
|
||||
}
|
||||
if callMsg.To != nil {
|
||||
call["to"] = strings.ToLower(callMsg.To.Hex())
|
||||
}
|
||||
if callMsg.Gas > 0 {
|
||||
call["gas"] = hexutil.EncodeUint64(callMsg.Gas)
|
||||
}
|
||||
if callMsg.GasPrice != nil {
|
||||
call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice)
|
||||
}
|
||||
if callMsg.Value != nil {
|
||||
call["value"] = hexutil.EncodeBig(callMsg.Value)
|
||||
}
|
||||
if len(callMsg.Data) > 0 {
|
||||
call["data"] = hexutil.Encode(callMsg.Data)
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func isTronNetwork(network shared.Network) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(network.Name)), "tron")
|
||||
}
|
||||
|
||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
||||
}
|
||||
if value.IsNegative() {
|
||||
return nil, merrors.InvalidArgument("amount must be positive")
|
||||
}
|
||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
||||
scaled := value.Mul(multiplier)
|
||||
if !scaled.Equal(scaled.Truncate(0)) {
|
||||
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
||||
}
|
||||
return scaled.BigInt(), nil
|
||||
}
|
||||
|
||||
func executorInvalid(msg string) error {
|
||||
return merrors.InvalidArgument("executor: " + msg)
|
||||
}
|
||||
|
||||
func executorInternal(msg string, err error) error {
|
||||
if err != nil {
|
||||
msg = msg + ": " + err.Error()
|
||||
}
|
||||
return merrors.Internal("executor: " + msg)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000)
|
||||
|
||||
// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount.
|
||||
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) {
|
||||
if wallet == nil {
|
||||
return nil, false, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
return nil, false, merrors.InvalidArgument("estimated fee is required")
|
||||
}
|
||||
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||
return nil, false, merrors.InvalidArgument("current native balance is required")
|
||||
}
|
||||
if network.GasTopUpPolicy == nil {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
|
||||
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||
if nativeCurrency == "" {
|
||||
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
|
||||
estimatedNative, err := evmToNative(estimatedFee)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
currentNative, err := evmToNative(currentBalance)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||
if !ok {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||
}
|
||||
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||
}
|
||||
|
||||
required := estimatedNative.Sub(currentNative)
|
||||
if required.IsNegative() {
|
||||
required = decimal.Zero
|
||||
}
|
||||
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||
|
||||
minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative)
|
||||
if minBalanceTopUp.IsNegative() {
|
||||
minBalanceTopUp = decimal.Zero
|
||||
}
|
||||
|
||||
rawTopUp := bufferedRequired
|
||||
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||
rawTopUp = minBalanceTopUp
|
||||
}
|
||||
|
||||
roundedTopUp := decimal.Zero
|
||||
if rawTopUp.IsPositive() {
|
||||
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||
}
|
||||
|
||||
topUp := roundedTopUp
|
||||
capHit := false
|
||||
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||
topUp = rule.MaxTopUp
|
||||
capHit = true
|
||||
}
|
||||
|
||||
if !topUp.IsPositive() {
|
||||
return nil, capHit, nil
|
||||
}
|
||||
|
||||
baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: strings.ToUpper(nativeCurrency),
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}, capHit, nil
|
||||
}
|
||||
|
||||
func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
return value.Div(evmBaseUnitFactor), nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30"))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "46000000000000000000", topUp.GetAmount())
|
||||
require.Equal(t, "ETH", topUp.GetCurrency())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "19000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := ethNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "2000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(10),
|
||||
},
|
||||
}
|
||||
network := ethNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.True(t, capHit)
|
||||
require.Equal(t, "10000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.1),
|
||||
MinNativeBalance: decimal.NewFromFloat(10),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
Contract: &shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.5),
|
||||
MinNativeBalance: decimal.NewFromFloat(5),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := ethNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||
return &shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.15),
|
||||
MinNativeBalance: decimal.NewFromFloat(20),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(500),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||
return shared.Network{
|
||||
Name: "ethereum_mainnet",
|
||||
NativeToken: "ETH",
|
||||
GasTopUpPolicy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
func ethMoney(eth string) *moneyv1.Money {
|
||||
value, _ := decimal.NewFromString(eth)
|
||||
baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: "ETH",
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
const tronHexPrefix = "0x"
|
||||
|
||||
var base58Alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
|
||||
|
||||
func normalizeAddress(address string) (string, error) {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if trimmed == "" {
|
||||
return "", merrors.InvalidArgument("address is required")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
|
||||
return hexToBase58(trimmed)
|
||||
}
|
||||
decoded, err := base58Decode(trimmed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateChecksum(decoded); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base58Encode(decoded), nil
|
||||
}
|
||||
|
||||
func rpcAddress(address string) (string, error) {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if trimmed == "" {
|
||||
return "", merrors.InvalidArgument("address is required")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
|
||||
return normalizeHexRPC(trimmed)
|
||||
}
|
||||
return base58ToHex(trimmed)
|
||||
}
|
||||
|
||||
func hexToBase58(address string) (string, error) {
|
||||
bytesAddr, err := parseHexAddress(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := append(bytesAddr, checksum(bytesAddr)...)
|
||||
return base58Encode(payload), nil
|
||||
}
|
||||
|
||||
func base58ToHex(address string) (string, error) {
|
||||
decoded, err := base58Decode(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateChecksum(decoded); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
|
||||
}
|
||||
|
||||
func parseHexAddress(address string) ([]byte, error) {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(address), tronHexPrefix)
|
||||
if trimmed == "" {
|
||||
return nil, merrors.InvalidArgument("address is required")
|
||||
}
|
||||
if len(trimmed)%2 == 1 {
|
||||
trimmed = "0" + trimmed
|
||||
}
|
||||
decoded, err := hex.DecodeString(trimmed)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid hex address")
|
||||
}
|
||||
switch len(decoded) {
|
||||
case 20:
|
||||
prefixed := make([]byte, 21)
|
||||
prefixed[0] = 0x41
|
||||
copy(prefixed[1:], decoded)
|
||||
return prefixed, nil
|
||||
case 21:
|
||||
if decoded[0] != 0x41 {
|
||||
return nil, merrors.InvalidArgument("invalid tron address prefix")
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid tron address length %d", len(decoded)))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHexRPC(address string) (string, error) {
|
||||
decoded, err := parseHexAddress(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
|
||||
}
|
||||
|
||||
func validateChecksum(decoded []byte) error {
|
||||
if len(decoded) != 25 {
|
||||
return merrors.InvalidArgument("invalid tron address length")
|
||||
}
|
||||
payload := decoded[:21]
|
||||
expected := checksum(payload)
|
||||
if !bytes.Equal(expected, decoded[21:]) {
|
||||
return merrors.InvalidArgument("invalid tron address checksum")
|
||||
}
|
||||
if payload[0] != 0x41 {
|
||||
return merrors.InvalidArgument("invalid tron address prefix")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksum(payload []byte) []byte {
|
||||
first := sha256.Sum256(payload)
|
||||
second := sha256.Sum256(first[:])
|
||||
return second[:4]
|
||||
}
|
||||
|
||||
func base58Encode(input []byte) string {
|
||||
if len(input) == 0 {
|
||||
return ""
|
||||
}
|
||||
x := new(big.Int).SetBytes(input)
|
||||
base := big.NewInt(58)
|
||||
zero := big.NewInt(0)
|
||||
mod := new(big.Int)
|
||||
|
||||
encoded := make([]byte, 0, len(input))
|
||||
for x.Cmp(zero) > 0 {
|
||||
x.DivMod(x, base, mod)
|
||||
encoded = append(encoded, base58Alphabet[mod.Int64()])
|
||||
}
|
||||
for _, b := range input {
|
||||
if b != 0 {
|
||||
break
|
||||
}
|
||||
encoded = append(encoded, base58Alphabet[0])
|
||||
}
|
||||
reverse(encoded)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func base58Decode(input string) ([]byte, error) {
|
||||
result := big.NewInt(0)
|
||||
base := big.NewInt(58)
|
||||
|
||||
for i := 0; i < len(input); i++ {
|
||||
idx := bytes.IndexByte(base58Alphabet, input[i])
|
||||
if idx < 0 {
|
||||
return nil, merrors.InvalidArgument("invalid base58 address")
|
||||
}
|
||||
result.Mul(result, base)
|
||||
result.Add(result, big.NewInt(int64(idx)))
|
||||
}
|
||||
|
||||
decoded := result.Bytes()
|
||||
zeroCount := 0
|
||||
for zeroCount < len(input) && input[zeroCount] == base58Alphabet[0] {
|
||||
zeroCount++
|
||||
}
|
||||
if zeroCount > 0 {
|
||||
decoded = append(make([]byte, zeroCount), decoded...)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func reverse(data []byte) {
|
||||
for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
|
||||
data[i], data[j] = data[j], data[i]
|
||||
}
|
||||
}
|
||||
|
||||
func isHexString(value string) bool {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(value), tronHexPrefix)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
245
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
245
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Driver implements Tron-specific behavior, including address conversion.
|
||||
type Driver struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger) *Driver {
|
||||
return &Driver{logger: logger.Named("tron")}
|
||||
}
|
||||
|
||||
func (d *Driver) Name() string {
|
||||
return "tron"
|
||||
}
|
||||
|
||||
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||
d.logger.Debug("Format address", zap.String("address", address))
|
||||
normalized, err := normalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||
normalized, err := normalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Balance address conversion failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("address", wallet.DepositAddress),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.Balance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||
if err != nil {
|
||||
d.logger.Warn("Balance failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance address conversion failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("address", wallet.DepositAddress),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Native balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if amount == nil {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
rpcFrom, err := rpcAddress(wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Estimate fee address conversion failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("address", wallet.DepositAddress),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
rpcTo, err := rpcAddress(destination)
|
||||
if err != nil {
|
||||
d.logger.Warn("Estimate fee destination conversion failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if rpcFrom == rpcTo {
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: "0",
|
||||
}, nil
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("Estimate fee failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("from_address", wallet.DepositAddress),
|
||||
zap.String("from_rpc", rpcFrom),
|
||||
zap.String("to_address", destination),
|
||||
zap.String("to_rpc", rpcTo),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||
if source == nil {
|
||||
return "", merrors.InvalidArgument("source wallet is required")
|
||||
}
|
||||
d.logger.Debug("Submit transfer request",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
rpcFrom, err := rpcAddress(source.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Submit transfer address conversion failed", zap.Error(err),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
zap.String("address", source.DepositAddress),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
rpcTo, err := rpcAddress(destination)
|
||||
if err != nil {
|
||||
d.logger.Warn("Submit transfer destination conversion failed", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("destination", destination),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
|
||||
if err != nil {
|
||||
d.logger.Warn("Submit transfer failed", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
} else {
|
||||
d.logger.Debug("Submit transfer result",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("tx_hash", txHash),
|
||||
)
|
||||
}
|
||||
return txHash, err
|
||||
}
|
||||
|
||||
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
d.logger.Debug("Awaiting confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||
if err != nil {
|
||||
d.logger.Warn("Awaiting of confirmation failed", zap.Error(err),
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
} else if receipt != nil {
|
||||
d.logger.Debug("Await confirmation result",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
zap.Uint64("status", receipt.Status),
|
||||
)
|
||||
}
|
||||
return receipt, err
|
||||
}
|
||||
|
||||
func nativeCurrency(network shared.Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
@@ -0,0 +1,33 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
d := New(logger)
|
||||
wallet := &model.ManagedWallet{
|
||||
WalletRef: "wallet_ref",
|
||||
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
|
||||
}
|
||||
network := shared.Network{
|
||||
Name: "tron_mainnet",
|
||||
NativeToken: "TRX",
|
||||
}
|
||||
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
|
||||
|
||||
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fee)
|
||||
require.Equal(t, "TRX", fee.GetCurrency())
|
||||
require.Equal(t, "0", fee.GetAmount())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
|
||||
|
||||
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
|
||||
type GasTopUpDecision struct {
|
||||
CurrentBalanceTRX decimal.Decimal
|
||||
EstimatedFeeTRX decimal.Decimal
|
||||
RequiredTRX decimal.Decimal
|
||||
BufferedRequiredTRX decimal.Decimal
|
||||
MinBalanceTopUpTRX decimal.Decimal
|
||||
RawTopUpTRX decimal.Decimal
|
||||
RoundedTopUpTRX decimal.Decimal
|
||||
TopUpTRX decimal.Decimal
|
||||
CapHit bool
|
||||
OperationType string
|
||||
}
|
||||
|
||||
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
|
||||
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
|
||||
decision := GasTopUpDecision{}
|
||||
if wallet == nil {
|
||||
return nil, decision, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
return nil, decision, merrors.InvalidArgument("estimated fee is required")
|
||||
}
|
||||
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||
return nil, decision, merrors.InvalidArgument("current native balance is required")
|
||||
}
|
||||
if network.GasTopUpPolicy == nil {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
|
||||
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||
if nativeCurrency == "" {
|
||||
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
|
||||
estimatedTRX, err := tronToTRX(estimatedFee)
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
currentTRX, err := tronToTRX(currentBalance)
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
|
||||
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||
if !ok {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||
}
|
||||
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||
}
|
||||
|
||||
required := estimatedTRX.Sub(currentTRX)
|
||||
if required.IsNegative() {
|
||||
required = decimal.Zero
|
||||
}
|
||||
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||
|
||||
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
|
||||
if minBalanceTopUp.IsNegative() {
|
||||
minBalanceTopUp = decimal.Zero
|
||||
}
|
||||
|
||||
rawTopUp := bufferedRequired
|
||||
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||
rawTopUp = minBalanceTopUp
|
||||
}
|
||||
|
||||
roundedTopUp := decimal.Zero
|
||||
if rawTopUp.IsPositive() {
|
||||
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||
}
|
||||
|
||||
topUp := roundedTopUp
|
||||
capHit := false
|
||||
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||
topUp = rule.MaxTopUp
|
||||
capHit = true
|
||||
}
|
||||
|
||||
decision = GasTopUpDecision{
|
||||
CurrentBalanceTRX: currentTRX,
|
||||
EstimatedFeeTRX: estimatedTRX,
|
||||
RequiredTRX: required,
|
||||
BufferedRequiredTRX: bufferedRequired,
|
||||
MinBalanceTopUpTRX: minBalanceTopUp,
|
||||
RawTopUpTRX: rawTopUp,
|
||||
RoundedTopUpTRX: roundedTopUp,
|
||||
TopUpTRX: topUp,
|
||||
CapHit: capHit,
|
||||
OperationType: operationType(isContract),
|
||||
}
|
||||
|
||||
if !topUp.IsPositive() {
|
||||
return nil, decision, nil
|
||||
}
|
||||
|
||||
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: strings.ToUpper(nativeCurrency),
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}, decision, nil
|
||||
}
|
||||
|
||||
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
return value.Div(tronBaseUnitFactor), nil
|
||||
}
|
||||
|
||||
func operationType(contract bool) string {
|
||||
if contract {
|
||||
return "trc20"
|
||||
}
|
||||
return "native"
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, topUp)
|
||||
require.True(t, decision.TopUpTRX.IsZero())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "46000000", topUp.GetAmount())
|
||||
require.Equal(t, "TRX", topUp.GetCurrency())
|
||||
require.Equal(t, "46", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "19000000", topUp.GetAmount())
|
||||
require.Equal(t, "19", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := tronNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "2000000", topUp.GetAmount())
|
||||
require.Equal(t, "2", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(10),
|
||||
},
|
||||
}
|
||||
network := tronNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "10000000", topUp.GetAmount())
|
||||
require.True(t, decision.CapHit)
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "15000000", topUp.GetAmount())
|
||||
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.1),
|
||||
MinNativeBalance: decimal.NewFromFloat(10),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
Contract: &shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.5),
|
||||
MinNativeBalance: decimal.NewFromFloat(5),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := tronNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "15000000", topUp.GetAmount())
|
||||
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||
require.Equal(t, "trc20", decision.OperationType)
|
||||
}
|
||||
|
||||
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||
return &shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.15),
|
||||
MinNativeBalance: decimal.NewFromFloat(20),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(500),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||
return shared.Network{
|
||||
Name: "tron_mainnet",
|
||||
NativeToken: "TRX",
|
||||
GasTopUpPolicy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
func tronMoney(trx string) *moneyv1.Money {
|
||||
value, _ := decimal.NewFromString(trx)
|
||||
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: "TRX",
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/ethereum"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Registry maps configured network keys to chain drivers.
|
||||
type Registry struct {
|
||||
byNetwork map[string]driver.Driver
|
||||
}
|
||||
|
||||
// NewRegistry selects drivers for the configured networks.
|
||||
func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("driver registry: logger is required")
|
||||
}
|
||||
result := &Registry{byNetwork: map[string]driver.Driver{}}
|
||||
for _, network := range networks {
|
||||
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
chainDriver, err := resolveDriver(logger, name)
|
||||
if err != nil {
|
||||
logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
result.byNetwork[name] = chainDriver
|
||||
}
|
||||
if len(result.byNetwork) == 0 {
|
||||
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
|
||||
}
|
||||
logger.Info("Chain drivers configured", zap.Int("count", len(result.byNetwork)))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Driver resolves a driver for the provided network key.
|
||||
func (r *Registry) Driver(network string) (driver.Driver, error) {
|
||||
if r == nil || len(r.byNetwork) == 0 {
|
||||
return nil, merrors.Internal("driver registry is not configured")
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(network))
|
||||
if key == "" {
|
||||
return nil, merrors.InvalidArgument("network is required")
|
||||
}
|
||||
chainDriver, ok := r.byNetwork[key]
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
|
||||
}
|
||||
return chainDriver, nil
|
||||
}
|
||||
|
||||
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(network, "tron"):
|
||||
return tron.New(logger), nil
|
||||
case strings.HasPrefix(network, "arbitrum"):
|
||||
return arbitrum.New(logger), nil
|
||||
case strings.HasPrefix(network, "ethereum"):
|
||||
return ethereum.New(logger), nil
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("unsupported chain network " + network)
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"errors"
|
||||
"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/ethereum/go-ethereum/rpc"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -30,11 +30,11 @@ type TransferExecutor interface {
|
||||
}
|
||||
|
||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
|
||||
return &onChainExecutor{
|
||||
logger: logger.Named("executor"),
|
||||
keyManager: keyManager,
|
||||
clients: map[string]*ethclient.Client{},
|
||||
clients: clients,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,49 +42,52 @@ type onChainExecutor struct {
|
||||
logger mlogger.Logger
|
||||
keyManager keymanager.Manager
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*ethclient.Client
|
||||
clients *rpcclient.Clients
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||
if o.keyManager == nil {
|
||||
o.logger.Error("key manager not configured")
|
||||
o.logger.Warn("Key manager not configured")
|
||||
return "", executorInternal("key manager is not configured", nil)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
|
||||
o.logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
||||
return "", executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
if source == nil || transfer == nil {
|
||||
o.logger.Error("transfer context missing")
|
||||
o.logger.Warn("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))
|
||||
o.logger.Warn("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))
|
||||
o.logger.Warn("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))
|
||||
o.logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||
}
|
||||
|
||||
o.logger.Info("submitting transfer",
|
||||
o.logger.Info("Submitting transfer",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("source_wallet_ref", source.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", strings.ToLower(destinationAddress)),
|
||||
)
|
||||
|
||||
client, err := o.getClient(ctx, rpcURL)
|
||||
client, err := o.clients.Client(network.Name)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to initialise rpc client",
|
||||
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
o.logger.Warn("Failed to initialise RPC client",
|
||||
zap.String("network", network.Name),
|
||||
zap.String("rpc_url", rpcURL),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", err
|
||||
@@ -98,17 +101,16 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to fetch nonce",
|
||||
o.logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("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",
|
||||
o.logger.Warn("Failed to suggest gas price",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
@@ -122,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
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))
|
||||
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",
|
||||
o.logger.Warn("Invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
@@ -135,34 +137,32 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
}
|
||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||
|
||||
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
||||
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to read token decimals",
|
||||
o.logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("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))
|
||||
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",
|
||||
o.logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||
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",
|
||||
o.logger.Warn("Failed to encode transfer call",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -177,7 +177,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to estimate gas",
|
||||
o.logger.Warn("Failed to estimate gas",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -188,24 +188,22 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to sign transaction",
|
||||
o.logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||
o.logger.Warn("failed to send transaction",
|
||||
o.logger.Warn("Failed to send transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", executorInternal("failed to send transaction", err)
|
||||
}
|
||||
|
||||
txHash = signedTx.Hash().Hex()
|
||||
o.logger.Info("transaction submitted",
|
||||
o.logger.Info("Transaction submitted",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
@@ -214,42 +212,18 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
return txHash, nil
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
|
||||
o.mu.Lock()
|
||||
client, ok := o.clients[rpcURL]
|
||||
o.mu.Unlock()
|
||||
if ok {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
c, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if existing, ok := o.clients[rpcURL]; ok {
|
||||
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
|
||||
c.Close()
|
||||
return existing, nil
|
||||
}
|
||||
o.clients[rpcURL] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
return nil, executorInvalid("tx hash is required")
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
return nil, executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
|
||||
client, err := o.getClient(ctx, rpcURL)
|
||||
client, err := o.clients.Client(network.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -264,27 +238,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
||||
if errors.Is(err, ethereum.NotFound) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
o.logger.Debug("transaction not yet mined",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
o.logger.Info("Transaction confirmed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
|
||||
}
|
||||
]`
|
||||
|
||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
||||
callData, err := erc20ABI.Pack("decimals")
|
||||
if err != nil {
|
||||
return 0, executorInternal("failed to encode decimals call", err)
|
||||
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||
call := map[string]string{
|
||||
"to": strings.ToLower(token.Hex()),
|
||||
"data": "0x313ce567",
|
||||
}
|
||||
msg := ethereum.CallMsg{
|
||||
To: &token,
|
||||
Data: callData,
|
||||
}
|
||||
output, err := client.CallContract(ctx, msg, nil)
|
||||
if err != nil {
|
||||
var hexResp string
|
||||
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||
return 0, executorInternal("decimals call failed", err)
|
||||
}
|
||||
values, err := erc20ABI.Unpack("decimals", output)
|
||||
val, err := shared.DecodeHexUint8(hexResp)
|
||||
if err != nil {
|
||||
return 0, executorInternal("failed to unpack decimals", err)
|
||||
return 0, executorInternal("decimals decode failed", 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
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
)
|
||||
@@ -18,10 +20,10 @@ func WithKeyManager(manager keymanager.Manager) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
||||
func WithTransferExecutor(executor TransferExecutor) Option {
|
||||
// WithRPCClients configures pre-initialised RPC clients.
|
||||
func WithRPCClients(clients *rpcclient.Clients) Option {
|
||||
return func(s *Service) {
|
||||
s.executor = executor
|
||||
s.rpcClients = clients
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDriverRegistry configures the chain driver registry.
|
||||
func WithDriverRegistry(registry *drivers.Registry) Option {
|
||||
return func(s *Service) {
|
||||
s.drivers = registry
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock overrides the service clock.
|
||||
func WithClock(clk clockpkg.Clock) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
204
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
204
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package rpcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/ethereum/go-ethereum/rpc"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Clients holds pre-initialised RPC clients keyed by network name.
|
||||
type Clients struct {
|
||||
logger mlogger.Logger
|
||||
clients map[string]clientEntry
|
||||
}
|
||||
|
||||
type clientEntry struct {
|
||||
eth *ethclient.Client
|
||||
rpc *rpc.Client
|
||||
}
|
||||
|
||||
// Prepare dials all configured networks up front and returns a ready-to-use client set.
|
||||
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.Internal("rpc clients: logger is required")
|
||||
}
|
||||
clientLogger := logger.Named("rpc_client")
|
||||
result := &Clients{
|
||||
logger: clientLogger,
|
||||
clients: make(map[string]clientEntry),
|
||||
}
|
||||
|
||||
for _, network := range networks {
|
||||
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if name == "" {
|
||||
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
|
||||
continue
|
||||
}
|
||||
if rpcURL == "" {
|
||||
result.Close()
|
||||
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
|
||||
clientLogger.Warn("Rpc url missing", zap.String("network", name))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("network", name),
|
||||
}
|
||||
clientLogger.Info("Initialising rpc client", fields...)
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
httpClient := &http.Client{
|
||||
Transport: &loggingRoundTripper{
|
||||
logger: clientLogger,
|
||||
network: name,
|
||||
endpoint: rpcURL,
|
||||
base: http.DefaultTransport,
|
||||
},
|
||||
}
|
||||
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
|
||||
cancel()
|
||||
if err != nil {
|
||||
result.Close()
|
||||
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
|
||||
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
||||
}
|
||||
client := ethclient.NewClient(rpcCli)
|
||||
result.clients[name] = clientEntry{
|
||||
eth: client,
|
||||
rpc: rpcCli,
|
||||
}
|
||||
clientLogger.Info("RPC client ready", fields...)
|
||||
}
|
||||
|
||||
if len(result.clients) == 0 {
|
||||
clientLogger.Warn("No rpc clients were initialised")
|
||||
return nil, merrors.InvalidArgument("no rpc clients initialised")
|
||||
} else {
|
||||
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Client returns a prepared client for the given network name.
|
||||
func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
||||
if c == nil {
|
||||
return nil, merrors.Internal("RPC clients not initialised")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(network))
|
||||
entry, ok := c.clients[name]
|
||||
if !ok || entry.eth == nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
|
||||
}
|
||||
return entry.eth, nil
|
||||
}
|
||||
|
||||
// RPCClient returns the raw RPC client for low-level calls.
|
||||
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
|
||||
if c == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(network))
|
||||
entry, ok := c.clients[name]
|
||||
if !ok || entry.rpc == nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
||||
}
|
||||
return entry.rpc, nil
|
||||
}
|
||||
|
||||
// Close tears down all RPC clients, logging each close.
|
||||
func (c *Clients) Close() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
for name, entry := range c.clients {
|
||||
if entry.rpc != nil {
|
||||
entry.rpc.Close()
|
||||
} else if entry.eth != nil {
|
||||
entry.eth.Close()
|
||||
}
|
||||
if c.logger != nil {
|
||||
c.logger.Info("RPC client closed", zap.String("network", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type loggingRoundTripper struct {
|
||||
logger mlogger.Logger
|
||||
network string
|
||||
endpoint string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if l.base == nil {
|
||||
l.base = http.DefaultTransport
|
||||
}
|
||||
|
||||
var reqBody []byte
|
||||
if req.Body != nil {
|
||||
raw, _ := io.ReadAll(req.Body)
|
||||
reqBody = raw
|
||||
req.Body = io.NopCloser(strings.NewReader(string(raw)))
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("network", l.network),
|
||||
}
|
||||
if len(reqBody) > 0 {
|
||||
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
|
||||
}
|
||||
l.logger.Debug("RPC request", fields...)
|
||||
|
||||
resp, err := l.base.RoundTrip(req)
|
||||
if err != nil {
|
||||
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
|
||||
|
||||
respFields := append(fields,
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
)
|
||||
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
|
||||
respFields = append(respFields, zap.String("content_type", contentType))
|
||||
}
|
||||
if len(bodyBytes) > 0 {
|
||||
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||
}
|
||||
l.logger.Debug("RPC response", respFields...)
|
||||
if resp.StatusCode >= 400 {
|
||||
l.logger.Warn("RPC response error", respFields...)
|
||||
} else if len(bodyBytes) == 0 {
|
||||
l.logger.Warn("RPC response empty body", respFields...)
|
||||
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
|
||||
l.logger.Warn("RPC response invalid JSON", respFields...)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 3 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package rpcclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/ethereum/go-ethereum/rpc"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
// Registry binds static network metadata with prepared RPC clients.
|
||||
type Registry struct {
|
||||
networks map[string]shared.Network
|
||||
clients *Clients
|
||||
}
|
||||
|
||||
// NewRegistry constructs a registry keyed by lower-cased network name.
|
||||
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
|
||||
return &Registry{
|
||||
networks: networks,
|
||||
clients: clients,
|
||||
}
|
||||
}
|
||||
|
||||
// Network fetches network metadata by key (case-insensitive).
|
||||
func (r *Registry) Network(key string) (shared.Network, bool) {
|
||||
if r == nil || len(r.networks) == 0 {
|
||||
return shared.Network{}, false
|
||||
}
|
||||
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Client returns the prepared RPC client for the given network name.
|
||||
func (r *Registry) Client(key string) (*ethclient.Client, error) {
|
||||
if r == nil || r.clients == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
|
||||
}
|
||||
|
||||
// RPCClient returns the raw RPC client for low-level calls.
|
||||
func (r *Registry) RPCClient(key string) (*rpc.Client, error) {
|
||||
if r == nil || r.clients == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key)))
|
||||
}
|
||||
|
||||
// Networks exposes the registry map for iteration when needed.
|
||||
func (r *Registry) Networks() map[string]shared.Network {
|
||||
return r.networks
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
@@ -38,11 +40,13 @@ type Service struct {
|
||||
|
||||
settings CacheSettings
|
||||
|
||||
networks map[string]shared.Network
|
||||
serviceWallet shared.ServiceWallet
|
||||
keyManager keymanager.Manager
|
||||
executor TransferExecutor
|
||||
commands commands.Registry
|
||||
networks map[string]shared.Network
|
||||
serviceWallet shared.ServiceWallet
|
||||
keyManager keymanager.Manager
|
||||
rpcClients *rpcclient.Clients
|
||||
networkRegistry *rpcclient.Registry
|
||||
drivers *drivers.Registry
|
||||
commands commands.Registry
|
||||
|
||||
chainv1.UnimplementedChainGatewayServiceServer
|
||||
}
|
||||
@@ -73,6 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.networks = map[string]shared.Network{}
|
||||
}
|
||||
svc.settings = svc.settings.withDefaults()
|
||||
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
|
||||
|
||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||
Wallet: commandsWalletDeps(svc),
|
||||
@@ -121,6 +126,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate
|
||||
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return errStorageUnavailable
|
||||
@@ -131,11 +144,13 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
func commandsWalletDeps(s *Service) wallet.Deps {
|
||||
return wallet.Deps{
|
||||
Logger: s.logger.Named("command"),
|
||||
Networks: s.networks,
|
||||
Drivers: s.drivers,
|
||||
Networks: s.networkRegistry,
|
||||
KeyManager: s.keyManager,
|
||||
Storage: s.storage,
|
||||
Clock: s.clock,
|
||||
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||
RPCTimeout: s.settings.rpcTimeout(),
|
||||
EnsureRepository: s.ensureRepository,
|
||||
}
|
||||
}
|
||||
@@ -143,9 +158,11 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
||||
func commandsTransferDeps(s *Service) transfer.Deps {
|
||||
return transfer.Deps{
|
||||
Logger: s.logger.Named("transfer_cmd"),
|
||||
Networks: s.networks,
|
||||
Drivers: s.drivers,
|
||||
Networks: s.networkRegistry,
|
||||
Storage: s.storage,
|
||||
Clock: s.clock,
|
||||
RPCTimeout: s.settings.rpcTimeout(),
|
||||
EnsureRepository: s.ensureRepository,
|
||||
LaunchExecution: s.launchTransferExecution,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
@@ -65,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
||||
require.Equal(t, 1, repo.wallets.count())
|
||||
}
|
||||
|
||||
func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: "idem-native",
|
||||
OrganizationRef: "org-1",
|
||||
OwnerRef: "owner-1",
|
||||
Asset: &ichainv1.Asset{
|
||||
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||
TokenSymbol: "ETH",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.GetWallet())
|
||||
require.Equal(t, "ETH", resp.GetWallet().GetAsset().GetTokenSymbol())
|
||||
require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress())
|
||||
}
|
||||
|
||||
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
||||
svc, repo := newTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -143,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
|
||||
require.Equal(t, codes.NotFound, st.Code())
|
||||
}
|
||||
|
||||
func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) {
|
||||
svc, repo := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: "idem-balance",
|
||||
OrganizationRef: "org-1",
|
||||
OwnerRef: "owner-1",
|
||||
Asset: &ichainv1.Asset{
|
||||
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||
TokenSymbol: "USDC",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
walletRef := createResp.GetWallet().GetWalletRef()
|
||||
|
||||
err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{
|
||||
WalletRef: walletRef,
|
||||
Available: &moneyv1.Money{Currency: "USDC", Amount: "25"},
|
||||
NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.5"},
|
||||
CalculatedAt: time.Now().UTC(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.GetBalance())
|
||||
require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount())
|
||||
require.Equal(t, "ETH", resp.GetBalance().GetNativeAvailable().GetCurrency())
|
||||
}
|
||||
|
||||
// ---- in-memory storage implementation ----
|
||||
|
||||
type inMemoryRepository struct {
|
||||
@@ -526,18 +577,23 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
|
||||
return int64(requested)
|
||||
}
|
||||
|
||||
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
||||
func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
||||
repo := newInMemoryRepository()
|
||||
logger := zap.NewNop()
|
||||
networks := []shared.Network{{
|
||||
Name: "ethereum_mainnet",
|
||||
NativeToken: "ETH",
|
||||
TokenConfigs: []shared.TokenContract{
|
||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||
},
|
||||
}}
|
||||
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
|
||||
require.NoError(t, err)
|
||||
svc := NewService(logger, repo, nil,
|
||||
WithKeyManager(&fakeKeyManager{}),
|
||||
WithNetworks([]shared.Network{{
|
||||
Name: "ethereum_mainnet",
|
||||
TokenConfigs: []shared.TokenContract{
|
||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||
},
|
||||
}}),
|
||||
WithNetworks(networks),
|
||||
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||
WithDriverRegistry(driverRegistry),
|
||||
)
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@ package gateway
|
||||
import "time"
|
||||
|
||||
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||
const defaultRPCRequestTimeout = 15 * time.Second
|
||||
|
||||
// CacheSettings holds tunable gateway behaviour.
|
||||
type CacheSettings struct {
|
||||
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
|
||||
}
|
||||
|
||||
func defaultSettings() CacheSettings {
|
||||
return CacheSettings{
|
||||
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +22,9 @@ func (s CacheSettings) withDefaults() CacheSettings {
|
||||
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||
}
|
||||
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -28,3 +34,10 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
||||
}
|
||||
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (s CacheSettings) rpcTimeout() time.Duration {
|
||||
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||
return defaultRPCRequestTimeout
|
||||
}
|
||||
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package shared
|
||||
|
||||
import "github.com/shopspring/decimal"
|
||||
|
||||
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
|
||||
type GasTopUpRule struct {
|
||||
BufferPercent decimal.Decimal
|
||||
MinNativeBalance decimal.Decimal
|
||||
RoundingUnit decimal.Decimal
|
||||
MaxTopUp decimal.Decimal
|
||||
}
|
||||
|
||||
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
|
||||
type GasTopUpPolicy struct {
|
||||
Default GasTopUpRule
|
||||
Native *GasTopUpRule
|
||||
Contract *GasTopUpRule
|
||||
}
|
||||
|
||||
// Rule selects the policy rule for the transfer type.
|
||||
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
|
||||
if p == nil {
|
||||
return GasTopUpRule{}, false
|
||||
}
|
||||
if contractTransfer && p.Contract != nil {
|
||||
return *p.Contract, true
|
||||
}
|
||||
if !contractTransfer && p.Native != nil {
|
||||
return *p.Native, true
|
||||
}
|
||||
return p.Default, true
|
||||
}
|
||||
@@ -119,13 +119,23 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// NativeCurrency returns the canonical native token symbol for a network.
|
||||
func NativeCurrency(network Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
// Network describes a supported blockchain network and known token contracts.
|
||||
type Network struct {
|
||||
Name string
|
||||
RPCURL string
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
Name string
|
||||
RPCURL string
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
GasTopUpPolicy *GasTopUpPolicy
|
||||
}
|
||||
|
||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||
|
||||
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
errHexEmpty = errors.New("hex value is empty")
|
||||
errHexInvalid = errors.New("invalid hex number")
|
||||
errHexOutOfRange = errors.New("hex number out of range")
|
||||
)
|
||||
|
||||
// DecodeHexBig parses a hex string that may include leading zero digits.
|
||||
func DecodeHexBig(input string) (*big.Int, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return nil, errHexEmpty
|
||||
}
|
||||
noPrefix := strings.TrimPrefix(trimmed, "0x")
|
||||
if noPrefix == "" {
|
||||
return nil, errHexEmpty
|
||||
}
|
||||
value := strings.TrimLeft(noPrefix, "0")
|
||||
if value == "" {
|
||||
return big.NewInt(0), nil
|
||||
}
|
||||
val := new(big.Int)
|
||||
if _, ok := val.SetString(value, 16); !ok {
|
||||
return nil, errHexInvalid
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros.
|
||||
func DecodeHexUint8(input string) (uint8, error) {
|
||||
val, err := DecodeHexBig(input)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if val == nil {
|
||||
return 0, errHexInvalid
|
||||
}
|
||||
if val.BitLen() > 8 {
|
||||
return 0, errHexOutOfRange
|
||||
}
|
||||
return uint8(val.Uint64()), nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package shared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeHexUint8_LeadingZeros(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const resp = "0x0000000000000000000000000000000000000000000000000000000000000006"
|
||||
val, err := DecodeHexUint8(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHexUint8 error: %v", err)
|
||||
}
|
||||
if val != 6 {
|
||||
t.Fatalf("DecodeHexUint8 value = %d, want 6", val)
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
)
|
||||
|
||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
||||
if s.executor == nil {
|
||||
if s.drivers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
||||
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))
|
||||
s.logger.Warn("Failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||
}
|
||||
}(transferRef, sourceWalletRef, network)
|
||||
}
|
||||
@@ -41,49 +41,73 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
|
||||
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
||||
driverDeps := s.driverDeps()
|
||||
chainDriver, err := s.driverForNetwork(network.Name)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
|
||||
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
||||
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
|
||||
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
|
||||
s.logger.Info("Self transfer detected; skipping submission",
|
||||
zap.String("transfer_ref", transferRef),
|
||||
zap.String("wallet_ref", sourceWalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
|
||||
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||
if err != nil {
|
||||
_, _ = 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))
|
||||
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)
|
||||
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, 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))
|
||||
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))
|
||||
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))
|
||||
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) {
|
||||
func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||
if err != nil {
|
||||
@@ -92,10 +116,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
return "", merrors.Internal("destination wallet missing deposit address")
|
||||
}
|
||||
return wallet.DepositAddress, nil
|
||||
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||
}
|
||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||
return strings.ToLower(addr), nil
|
||||
return chainDriver.NormalizeAddress(addr)
|
||||
}
|
||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||
}
|
||||
|
||||
func (s *Service) driverDeps() driver.Deps {
|
||||
return driver.Deps{
|
||||
Logger: s.logger.Named("driver"),
|
||||
Registry: s.networkRegistry,
|
||||
KeyManager: s.keyManager,
|
||||
RPCTimeout: s.settings.rpcTimeout(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
|
||||
if s.drivers == nil {
|
||||
return nil, merrors.Internal("chain drivers not configured")
|
||||
}
|
||||
return s.drivers.Driver(network)
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ type ServiceFee struct {
|
||||
}
|
||||
|
||||
type TransferDestination struct {
|
||||
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
|
||||
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
|
||||
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
|
||||
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
|
||||
ExternalAddressOriginal string `bson:"externalAddressOriginal,omitempty" json:"externalAddressOriginal,omitempty"`
|
||||
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
// Transfer models an on-chain transfer orchestrated by the gateway.
|
||||
@@ -85,7 +86,8 @@ func (t *Transfer) Normalize() {
|
||||
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.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress)
|
||||
t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal)
|
||||
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
|
||||
t.ClientReference = strings.TrimSpace(t.ClientReference)
|
||||
}
|
||||
|
||||
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) {
|
||||
address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||
transfer := &Transfer{
|
||||
IdempotencyKey: "idemp",
|
||||
TransferRef: "ref",
|
||||
OrganizationRef: "org",
|
||||
SourceWalletRef: "wallet",
|
||||
Network: "tron_mainnet",
|
||||
TokenSymbol: "USDT",
|
||||
Destination: TransferDestination{
|
||||
ExternalAddress: address,
|
||||
ExternalAddressOriginal: address,
|
||||
},
|
||||
}
|
||||
|
||||
transfer.Normalize()
|
||||
|
||||
if transfer.Destination.ExternalAddress != address {
|
||||
t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress)
|
||||
}
|
||||
if transfer.Destination.ExternalAddressOriginal != address {
|
||||
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) {
|
||||
address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||
transfer := &Transfer{
|
||||
Destination: TransferDestination{
|
||||
ExternalAddress: address,
|
||||
ExternalAddressOriginal: address,
|
||||
},
|
||||
}
|
||||
|
||||
transfer.Normalize()
|
||||
|
||||
if transfer.Destination.ExternalAddress != strings.ToLower(address) {
|
||||
t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress)
|
||||
}
|
||||
if transfer.Destination.ExternalAddressOriginal != address {
|
||||
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
@@ -19,7 +20,8 @@ const (
|
||||
|
||||
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
||||
type ManagedWallet struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
pkgmodel.Describable `bson:",inline" json:",inline"`
|
||||
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||
@@ -45,6 +47,7 @@ type WalletBalance struct {
|
||||
|
||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||
Available *moneyv1.Money `bson:"available" json:"available"`
|
||||
NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"`
|
||||
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
||||
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
||||
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
||||
@@ -77,10 +80,19 @@ func (m *ManagedWallet) Normalize() {
|
||||
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
||||
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
||||
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
||||
m.Name = strings.TrimSpace(m.Name)
|
||||
if m.Description != nil {
|
||||
desc := strings.TrimSpace(*m.Description)
|
||||
if desc == "" {
|
||||
m.Description = nil
|
||||
} else {
|
||||
m.Description = &desc
|
||||
}
|
||||
}
|
||||
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
|
||||
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
|
||||
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
||||
}
|
||||
|
||||
@@ -88,3 +100,31 @@ func (m *ManagedWallet) Normalize() {
|
||||
func (b *WalletBalance) Normalize() {
|
||||
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
||||
}
|
||||
|
||||
func normalizeWalletAddress(address string) string {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if isHexAddress(trimmed) {
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isHexAddress(value string) bool {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x")
|
||||
if len(trimmed) != 40 && len(trimmed) != 42 {
|
||||
return false
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -44,23 +44,23 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
defer cancel()
|
||||
|
||||
if err := result.Ping(ctx); err != nil {
|
||||
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err))
|
||||
result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walletsStore, err := store.NewWallets(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("failed to initialise wallets store", zap.Error(err))
|
||||
result.logger.Error("Failed to initialise wallets store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
transfersStore, err := store.NewTransfers(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("failed to initialise transfers store", zap.Error(err))
|
||||
result.logger.Error("Failed to initialise transfers store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
depositsStore, err := store.NewDeposits(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("failed to initialise deposits store", zap.Error(err))
|
||||
result.logger.Error("Failed to initialise deposits store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,13 @@ func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
|
||||
}
|
||||
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()))
|
||||
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")
|
||||
childLogger.Debug("Deposits store initialised")
|
||||
|
||||
return &Deposits{logger: childLogger, repo: repo}, nil
|
||||
}
|
||||
|
||||
@@ -53,13 +53,13 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
|
||||
}
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
logger.Error("Failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
childLogger := logger.Named("transfers")
|
||||
childLogger.Debug("transfers store initialised")
|
||||
childLogger.Debug("Transfers store initialised")
|
||||
|
||||
return &Transfers{
|
||||
logger: childLogger,
|
||||
@@ -89,12 +89,12 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode
|
||||
}
|
||||
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
t.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
|
||||
t.logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
|
||||
return transfer, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
t.logger.Debug("transfer created", zap.String("transfer_ref", transfer.TransferRef))
|
||||
t.logger.Debug("Transfer created", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*mod
|
||||
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))
|
||||
t.logger.Warn("Ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
||||
}
|
||||
for _, def := range walletIndexes {
|
||||
if err := walletRepo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
|
||||
logger.Error("Failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,13 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
||||
}
|
||||
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))
|
||||
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")
|
||||
childLogger.Debug("Wallet stores initialised")
|
||||
|
||||
return &Wallets{
|
||||
logger: childLogger,
|
||||
@@ -99,24 +99,49 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
|
||||
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("idempotency_key", wallet.IdempotencyKey),
|
||||
}
|
||||
if wallet.OrganizationRef != "" {
|
||||
fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef))
|
||||
}
|
||||
if wallet.OwnerRef != "" {
|
||||
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
|
||||
}
|
||||
if wallet.Network != "" {
|
||||
fields = append(fields, zap.String("network", wallet.Network))
|
||||
}
|
||||
if wallet.TokenSymbol != "" {
|
||||
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
|
||||
}
|
||||
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
|
||||
w.logger.Debug("Wallet already exists", fields...)
|
||||
return wallet, nil
|
||||
}
|
||||
w.logger.Warn("Wallet create failed", append(fields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
|
||||
w.logger.Debug("Wallet created", fields...)
|
||||
return wallet, nil
|
||||
}
|
||||
|
||||
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
if walletRef == "" {
|
||||
func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
|
||||
walletID = strings.TrimSpace(walletID)
|
||||
if walletID == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_id", walletID),
|
||||
}
|
||||
wallet := &model.ManagedWallet{}
|
||||
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
|
||||
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
w.logger.Debug("Wallet not found", fields...)
|
||||
} else {
|
||||
w.logger.Warn("Wallet lookup failed", append(fields, zap.Error(err))...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return wallet, nil
|
||||
@@ -124,29 +149,38 @@ func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWall
|
||||
|
||||
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||
query := repository.Query()
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
|
||||
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
||||
query = query.Filter(repository.Field("organizationRef"), org)
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
||||
query = query.Filter(repository.Field("ownerRef"), owner)
|
||||
fields = append(fields, zap.String("owner_ref", owner))
|
||||
}
|
||||
if network := strings.TrimSpace(filter.Network); network != "" {
|
||||
query = query.Filter(repository.Field("network"), strings.ToLower(network))
|
||||
normalized := strings.ToLower(network)
|
||||
query = query.Filter(repository.Field("network"), normalized)
|
||||
fields = append(fields, zap.String("network", normalized))
|
||||
}
|
||||
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
||||
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
|
||||
normalized := strings.ToUpper(token)
|
||||
query = query.Filter(repository.Field("tokenSymbol"), normalized)
|
||||
fields = append(fields, zap.String("token_symbol", normalized))
|
||||
}
|
||||
|
||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||
fields = append(fields, zap.String("cursor", cursor))
|
||||
} else {
|
||||
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
w.logger.Warn("Ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
limit := sanitizeWalletLimit(filter.Limit)
|
||||
fields = append(fields, zap.Int64("limit", limit))
|
||||
fetchLimit := limit + 1
|
||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||
|
||||
@@ -160,8 +194,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
|
||||
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
|
||||
w.logger.Warn("Wallet list failed", append(fields, zap.Error(listErr))...)
|
||||
return nil, listErr
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
@@ -171,10 +207,21 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
||||
wallets = wallets[:len(wallets)-1]
|
||||
}
|
||||
|
||||
return &model.ManagedWalletList{
|
||||
result := &model.ManagedWalletList{
|
||||
Items: wallets,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fields = append(fields,
|
||||
zap.Int("count", len(result.Items)),
|
||||
zap.String("next_cursor", result.NextCursor),
|
||||
)
|
||||
if errors.Is(listErr, merrors.ErrNoData) {
|
||||
w.logger.Debug("Wallet list empty", fields...)
|
||||
} else {
|
||||
w.logger.Debug("Wallet list fetched", fields...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
||||
@@ -188,6 +235,7 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
||||
if balance.CalculatedAt.IsZero() {
|
||||
balance.CalculatedAt = time.Now().UTC()
|
||||
}
|
||||
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
|
||||
|
||||
existing := &model.WalletBalance{}
|
||||
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
||||
@@ -198,28 +246,40 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
||||
existing.PendingOutbound = balance.PendingOutbound
|
||||
existing.CalculatedAt = balance.CalculatedAt
|
||||
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
||||
w.logger.Warn("Wallet balance update failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("Wallet balance updated", fields...)
|
||||
return nil
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
||||
w.logger.Warn("Wallet balance create failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("Wallet balance created", fields...)
|
||||
return nil
|
||||
default:
|
||||
w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
if walletRef == "" {
|
||||
func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
|
||||
walletID = strings.TrimSpace(walletID)
|
||||
if walletID == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||
}
|
||||
fields := []zap.Field{zap.String("wallet_ref", walletID)}
|
||||
balance := &model.WalletBalance{}
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
w.logger.Debug("Wallet balance not found", fields...)
|
||||
} else {
|
||||
w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.logger.Debug("Wallet balance fetched", fields...)
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
@@ -23,6 +24,7 @@ type gatewayClient struct {
|
||||
conn *grpc.ClientConn
|
||||
client mntxv1.MntxGatewayServiceClient
|
||||
cfg Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// New dials the Monetix gateway.
|
||||
@@ -47,6 +49,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
conn: conn,
|
||||
client: mntxv1.NewMntxGatewayServiceClient(conn),
|
||||
cfg: cfg,
|
||||
logger: cfg.Logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -57,28 +60,39 @@ func (g *gatewayClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timeout := g.cfg.CallTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("method", method),
|
||||
zap.Duration("timeout", timeout),
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
|
||||
}
|
||||
g.logger.Info("Mntx gateway client call timeout applied", fields...)
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||
ctx, cancel := g.callContext(ctx)
|
||||
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
|
||||
defer cancel()
|
||||
return g.client.CreateCardPayout(ctx, req)
|
||||
}
|
||||
|
||||
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||
ctx, cancel := g.callContext(ctx)
|
||||
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
|
||||
defer cancel()
|
||||
return g.client.CreateCardTokenPayout(ctx, req)
|
||||
}
|
||||
|
||||
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||
ctx, cancel := g.callContext(ctx)
|
||||
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
|
||||
defer cancel()
|
||||
return g.client.GetCardPayoutStatus(ctx, req)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package client
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Config holds Monetix gateway client settings.
|
||||
type Config struct {
|
||||
Address string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
@@ -16,4 +21,7 @@ func (c *Config) setDefaults() {
|
||||
if c.CallTimeout <= 0 {
|
||||
c.CallTimeout = 10 * time.Second
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = zap.NewNop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ monetix:
|
||||
base_url_env: MONETIX_BASE_URL
|
||||
project_id_env: MONETIX_PROJECT_ID
|
||||
secret_key_env: MONETIX_SECRET_KEY
|
||||
allowed_currencies: ["USD", "EUR"]
|
||||
allowed_currencies: ["RUB"]
|
||||
require_customer_address: false
|
||||
request_timeout_seconds: 15
|
||||
status_success: "success"
|
||||
|
||||
@@ -11,8 +11,8 @@ require (
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ require (
|
||||
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/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
@@ -50,5 +50,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
)
|
||||
|
||||
@@ -95,8 +95,8 @@ 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/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -214,12 +214,12 @@ 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-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -95,22 +95,49 @@ func (i *Imp) Shutdown() {
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
i.logger.Info("Starting Monetix gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
||||
|
||||
cfg, err := i.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
i.logger.Info("Configuration loaded",
|
||||
zap.String("grpc_address", cfg.GRPC.Address),
|
||||
zap.String("metrics_address", cfg.Metrics.Address),
|
||||
)
|
||||
|
||||
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to resolve callback configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
i.logger.Info("Monetix configuration resolved",
|
||||
zap.Bool("base_url_set", strings.TrimSpace(monetixCfg.BaseURL) != ""),
|
||||
zap.Int64("project_id", monetixCfg.ProjectID),
|
||||
zap.Bool("secret_key_set", strings.TrimSpace(monetixCfg.SecretKey) != ""),
|
||||
zap.Int("allowed_currencies", len(monetixCfg.AllowedCurrencies)),
|
||||
zap.Bool("require_customer_address", monetixCfg.RequireCustomerAddress),
|
||||
zap.Duration("request_timeout", monetixCfg.RequestTimeout),
|
||||
zap.String("status_success", monetixCfg.SuccessStatus()),
|
||||
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
||||
)
|
||||
|
||||
i.logger.Info("Callback configuration resolved",
|
||||
zap.String("address", callbackCfg.Address),
|
||||
zap.String("path", callbackCfg.Path),
|
||||
zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)),
|
||||
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
|
||||
)
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
||||
svc := mntxservice.NewService(logger,
|
||||
mntxservice.WithProducer(producer),
|
||||
@@ -137,7 +164,7 @@ func (i *Imp) Start() error {
|
||||
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))
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -145,7 +172,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
Config: &grpcapp.Config{},
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("failed to parse configuration", zap.Error(err))
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -245,7 +272,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
||||
}
|
||||
_, block, err := net.ParseCIDR(clean)
|
||||
if err != nil {
|
||||
i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||
i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
cidrs = append(cidrs, block)
|
||||
@@ -270,20 +297,36 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||
log := i.logger.Named("callback_http")
|
||||
log.Debug("Callback request received",
|
||||
zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
)
|
||||
|
||||
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
||||
ip := clientIPFromRequest(r)
|
||||
remoteIP := ""
|
||||
if ip != nil {
|
||||
remoteIP = ip.String()
|
||||
}
|
||||
log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP))
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
||||
if err != nil {
|
||||
log.Warn("Callback body read failed", zap.Error(err))
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
||||
if err != nil {
|
||||
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
log.Debug("Callback processed", zap.Int("status", status))
|
||||
w.WriteHeader(status)
|
||||
})
|
||||
|
||||
@@ -301,7 +344,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
|
||||
i.logger.Warn("Monetix callback server stopped with error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientIPFromRequest(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}},
|
||||
RemoteAddr: "9.8.7.6:1234",
|
||||
}
|
||||
ip := clientIPFromRequest(req)
|
||||
if ip == nil || ip.String() != "1.2.3.4" {
|
||||
t.Fatalf("expected forwarded ip, got %v", ip)
|
||||
}
|
||||
|
||||
req = &http.Request{RemoteAddr: "9.8.7.6:1234"}
|
||||
ip = clientIPFromRequest(req)
|
||||
if ip == nil || ip.String() != "9.8.7.6" {
|
||||
t.Fatalf("expected remote addr ip, got %v", ip)
|
||||
}
|
||||
|
||||
req = &http.Request{RemoteAddr: "invalid"}
|
||||
ip = clientIPFromRequest(req)
|
||||
if ip != nil {
|
||||
t.Fatalf("expected nil ip, got %v", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAllowed(t *testing.T) {
|
||||
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse cidr: %v", err)
|
||||
}
|
||||
|
||||
allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
||||
if !clientAllowed(allowedReq, []*net.IPNet{cidr}) {
|
||||
t.Fatalf("expected allowed request")
|
||||
}
|
||||
|
||||
deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||
if clientAllowed(deniedReq, []*net.IPNet{cidr}) {
|
||||
t.Fatalf("expected denied request")
|
||||
}
|
||||
|
||||
openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||
if !clientAllowed(openReq, nil) {
|
||||
t.Fatalf("expected allow when no cidrs are configured")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -66,9 +67,12 @@ type monetixCallback struct {
|
||||
|
||||
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
||||
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
||||
log := s.logger.Named("callback")
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload)))
|
||||
return s.card.ProcessCallback(ctx, payload)
|
||||
}
|
||||
|
||||
|
||||
130
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
130
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (f fixedClock) Now() time.Time {
|
||||
return f.now
|
||||
}
|
||||
|
||||
func baseCallback() monetixCallback {
|
||||
cb := monetixCallback{
|
||||
ProjectID: 42,
|
||||
}
|
||||
cb.Payment.ID = "payout-1"
|
||||
cb.Payment.Status = "success"
|
||||
cb.Payment.Sum.Amount = 5000
|
||||
cb.Payment.Sum.Currency = "usd"
|
||||
cb.Customer.ID = "cust-1"
|
||||
cb.Operation.Status = "success"
|
||||
cb.Operation.Code = ""
|
||||
cb.Operation.Message = "ok"
|
||||
cb.Operation.RequestID = "req-1"
|
||||
cb.Operation.Provider.PaymentID = "prov-1"
|
||||
return cb
|
||||
}
|
||||
|
||||
func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
||||
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
cfg := monetix.DefaultConfig()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
paymentStatus string
|
||||
operationStatus string
|
||||
code string
|
||||
expectedStatus mntxv1.PayoutStatus
|
||||
expectedOutcome string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
paymentStatus: "success",
|
||||
operationStatus: "success",
|
||||
code: "0",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
|
||||
expectedOutcome: monetix.OutcomeSuccess,
|
||||
},
|
||||
{
|
||||
name: "processing",
|
||||
paymentStatus: "processing",
|
||||
operationStatus: "success",
|
||||
code: "",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||
expectedOutcome: monetix.OutcomeProcessing,
|
||||
},
|
||||
{
|
||||
name: "decline",
|
||||
paymentStatus: "failed",
|
||||
operationStatus: "failed",
|
||||
code: "1",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
|
||||
expectedOutcome: monetix.OutcomeDecline,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cb := baseCallback()
|
||||
cb.Payment.Status = tc.paymentStatus
|
||||
cb.Operation.Status = tc.operationStatus
|
||||
cb.Operation.Code = tc.code
|
||||
|
||||
state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb)
|
||||
if state.Status != tc.expectedStatus {
|
||||
t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status)
|
||||
}
|
||||
if outcome != tc.expectedOutcome {
|
||||
t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome)
|
||||
}
|
||||
if state.Currency != "USD" {
|
||||
t.Fatalf("expected currency USD, got %q", state.Currency)
|
||||
}
|
||||
if !state.UpdatedAt.AsTime().Equal(now) {
|
||||
t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackProviderPaymentID(t *testing.T) {
|
||||
cb := baseCallback()
|
||||
if got := fallbackProviderPaymentID(cb); got != "prov-1" {
|
||||
t.Fatalf("expected provider payment id, got %q", got)
|
||||
}
|
||||
cb.Operation.Provider.PaymentID = ""
|
||||
if got := fallbackProviderPaymentID(cb); got != "req-1" {
|
||||
t.Fatalf("expected request id fallback, got %q", got)
|
||||
}
|
||||
cb.Operation.RequestID = ""
|
||||
if got := fallbackProviderPaymentID(cb); got != "payout-1" {
|
||||
t.Fatalf("expected payment id fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCallbackSignature(t *testing.T) {
|
||||
secret := "secret"
|
||||
cb := baseCallback()
|
||||
|
||||
sig, err := monetix.SignPayload(cb, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
if err := verifyCallbackSignature(cb, secret); err != nil {
|
||||
t.Fatalf("expected valid signature, got %v", err)
|
||||
}
|
||||
|
||||
cb.Signature = "invalid"
|
||||
if err := verifyCallbackSignature(cb, secret); err == nil {
|
||||
t.Fatalf("expected signature mismatch error")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -17,14 +18,24 @@ func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRe
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
||||
log := s.logger.Named("card_payout")
|
||||
log.Info("Create card payout request received",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
resp, err := s.card.Submit(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn("Card payout submission failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -33,14 +44,24 @@ func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTok
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
||||
log := s.logger.Named("card_token_payout")
|
||||
log.Info("Create card token payout request received",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
resp, err := s.card.SubmitToken(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn("Card token payout submission failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -49,14 +70,22 @@ func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeR
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
||||
log := s.logger.Named("card_tokenize")
|
||||
log.Info("Create card token request received",
|
||||
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
)
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
resp, err := s.card.Tokenize(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn("Card tokenization failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -65,14 +94,19 @@ func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPa
|
||||
}
|
||||
|
||||
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
||||
log := s.logger.Named("card_payout_status")
|
||||
log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())))
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
||||
if err != nil {
|
||||
log.Warn("Card payout status lookup failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardPayoutRequest_Valid(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardPayoutRequest()
|
||||
if err := validateCardPayoutRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardPayoutRequest_Errors(t *testing.T) {
|
||||
baseCfg := testMonetixConfig()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*mntxv1.CardPayoutRequest)
|
||||
config func(monetix.Config) monetix.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" },
|
||||
expected: "missing_customer_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_ip",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" },
|
||||
expected: "missing_customer_ip",
|
||||
},
|
||||
{
|
||||
name: "invalid_amount",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 },
|
||||
expected: "invalid_amount",
|
||||
},
|
||||
{
|
||||
name: "missing_currency",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" },
|
||||
expected: "missing_currency",
|
||||
},
|
||||
{
|
||||
name: "unsupported_currency",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.AllowedCurrencies = []string{"USD"}
|
||||
return cfg
|
||||
},
|
||||
expected: "unsupported_currency",
|
||||
},
|
||||
{
|
||||
name: "missing_card_pan",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" },
|
||||
expected: "missing_card_pan",
|
||||
},
|
||||
{
|
||||
name: "missing_card_holder",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" },
|
||||
expected: "missing_card_holder",
|
||||
},
|
||||
{
|
||||
name: "invalid_expiry_month",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 },
|
||||
expected: "invalid_expiry_month",
|
||||
},
|
||||
{
|
||||
name: "invalid_expiry_year",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 },
|
||||
expected: "invalid_expiry_year",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_country_when_required",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.RequireCustomerAddress = true
|
||||
return cfg
|
||||
},
|
||||
expected: "missing_customer_country",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := validCardPayoutRequest()
|
||||
tc.mutate(req)
|
||||
cfg := baseCfg
|
||||
if tc.config != nil {
|
||||
cfg = tc.config(cfg)
|
||||
}
|
||||
err := validateCardPayoutRequest(req, cfg)
|
||||
requireReason(t, err, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,20 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Info("Submitting card payout",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||
p.logger.Warn("monetix configuration is incomplete for payout submission")
|
||||
p.logger.Warn("Monetix configuration is incomplete for payout submission")
|
||||
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||
}
|
||||
|
||||
req = sanitizeCardPayoutRequest(req)
|
||||
if err := validateCardPayoutRequest(req, p.config); err != nil {
|
||||
p.logger.Warn("card payout validation failed",
|
||||
p.logger.Warn("Card payout validation failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -65,7 +71,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
if projectID == 0 {
|
||||
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
return nil, merrors.Internal("monetix project_id is not configured")
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
p.logger.Warn("monetix payout submission failed",
|
||||
p.logger.Warn("Monetix payout submission failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -122,6 +128,13 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
|
||||
p.logger.Info("Card payout submission stored",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", state.GetStatus().String()),
|
||||
zap.Bool("accepted", result.Accepted),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -129,14 +142,20 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Info("Submitting card token payout",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||
p.logger.Warn("monetix configuration is incomplete for token payout submission")
|
||||
p.logger.Warn("Monetix configuration is incomplete for token payout submission")
|
||||
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||
}
|
||||
|
||||
req = sanitizeCardTokenPayoutRequest(req)
|
||||
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
|
||||
p.logger.Warn("card token payout validation failed",
|
||||
p.logger.Warn("Card token payout validation failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -149,7 +168,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
if projectID == 0 {
|
||||
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
return nil, merrors.Internal("monetix project_id is not configured")
|
||||
}
|
||||
|
||||
@@ -179,7 +198,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
p.logger.Warn("monetix token payout submission failed",
|
||||
p.logger.Warn("Monetix token payout submission failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -206,6 +225,13 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
|
||||
p.logger.Info("Card token payout submission stored",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", state.GetStatus().String()),
|
||||
zap.Bool("accepted", result.Accepted),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -213,9 +239,13 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Info("Submitting card tokenization",
|
||||
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
)
|
||||
cardInput, err := validateCardTokenizeRequest(req, p.config)
|
||||
if err != nil {
|
||||
p.logger.Warn("card tokenization validation failed",
|
||||
p.logger.Warn("Card tokenization validation failed",
|
||||
zap.String("request_id", req.GetRequestId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -228,7 +258,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
if projectID == 0 {
|
||||
p.logger.Warn("monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
||||
p.logger.Warn("Monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
||||
return nil, merrors.Internal("monetix project_id is not configured")
|
||||
}
|
||||
|
||||
@@ -238,7 +268,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
|
||||
result, err := client.CreateCardTokenization(ctx, apiReq)
|
||||
if err != nil {
|
||||
p.logger.Warn("monetix tokenization request failed",
|
||||
p.logger.Warn("Monetix tokenization request failed",
|
||||
zap.String("request_id", req.GetRequestId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -258,6 +288,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
resp.ExpiryYear = result.ExpiryYear
|
||||
resp.CardBrand = result.CardBrand
|
||||
|
||||
p.logger.Info("Card tokenization completed",
|
||||
zap.String("request_id", resp.GetRequestId()),
|
||||
zap.Bool("success", resp.GetSuccess()),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -267,16 +303,18 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(payoutID)
|
||||
p.logger.Info("Card payout status requested", zap.String("payout_id", id))
|
||||
if id == "" {
|
||||
p.logger.Warn("payout status requested with empty payout_id")
|
||||
p.logger.Warn("Payout status requested with empty payout_id")
|
||||
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
|
||||
}
|
||||
|
||||
state, ok := p.store.Get(id)
|
||||
if !ok || state == nil {
|
||||
p.logger.Warn("payout status not found", zap.String("payout_id", id))
|
||||
p.logger.Warn("Payout status not found", zap.String("payout_id", id))
|
||||
return nil, merrors.NoData("payout not found")
|
||||
}
|
||||
p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -284,18 +322,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
if p == nil {
|
||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Debug("Processing Monetix callback", zap.Int("payload_bytes", len(payload)))
|
||||
if len(payload) == 0 {
|
||||
p.logger.Warn("received empty Monetix callback payload")
|
||||
p.logger.Warn("Received empty Monetix callback payload")
|
||||
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
|
||||
}
|
||||
if strings.TrimSpace(p.config.SecretKey) == "" {
|
||||
p.logger.Warn("monetix secret key is not configured; cannot verify callback")
|
||||
p.logger.Warn("Monetix secret key is not configured; cannot verify callback")
|
||||
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
|
||||
}
|
||||
|
||||
var cb monetixCallback
|
||||
if err := json.Unmarshal(payload, &cb); err != nil {
|
||||
p.logger.Warn("failed to unmarshal Monetix callback", zap.Error(err))
|
||||
p.logger.Warn("Failed to unmarshal Monetix callback", zap.Error(err))
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
@@ -304,7 +343,12 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
return http.StatusBadRequest, merrors.InvalidArgument("signature is missing")
|
||||
}
|
||||
if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil {
|
||||
p.logger.Warn("Monetix callback signature check failed", zap.Error(err))
|
||||
p.logger.Warn("Monetix callback signature check failed",
|
||||
zap.String("payout_id", cb.Payment.ID),
|
||||
zap.String("signature", cb.Signature),
|
||||
zap.String("payload", string(payload)),
|
||||
zap.Error(err),
|
||||
)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
@@ -337,16 +381,16 @@ func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState)
|
||||
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
|
||||
payload, err := protojson.Marshal(event)
|
||||
if err != nil {
|
||||
p.logger.Warn("failed to marshal payout callback event", zap.Error(err))
|
||||
p.logger.Warn("Failed to marshal payout callback event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
|
||||
if _, err := env.Wrap(payload); err != nil {
|
||||
p.logger.Warn("failed to wrap payout callback event payload", zap.Error(err))
|
||||
p.logger.Warn("Failed to wrap payout callback event payload", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := p.producer.SendMessage(env); err != nil {
|
||||
p.logger.Warn("failed to publish payout callback event", zap.Error(err))
|
||||
p.logger.Warn("Failed to publish payout callback event", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
149
api/gateway/mntx/internal/service/gateway/card_processor_test.go
Normal file
149
api/gateway/mntx/internal/service/gateway/card_processor_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
type staticClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (s staticClock) Now() time.Time {
|
||||
return s.now
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
existingCreated := timestamppb.New(time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC))
|
||||
store := newCardPayoutStore()
|
||||
store.Save(&mntxv1.CardPayoutState{
|
||||
PayoutId: "payout-1",
|
||||
CreatedAt: existingCreated,
|
||||
})
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
resp := monetix.APIResponse{}
|
||||
resp.Operation.RequestID = "req-123"
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil)
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
req.ProjectId = 0
|
||||
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted payout response")
|
||||
}
|
||||
if resp.GetPayout().GetProjectId() != cfg.ProjectID {
|
||||
t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId())
|
||||
}
|
||||
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING {
|
||||
t.Fatalf("expected pending status, got %v", resp.GetPayout().GetStatus())
|
||||
}
|
||||
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated.AsTime()) {
|
||||
t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime())
|
||||
}
|
||||
|
||||
stored, ok := store.Get(req.GetPayoutId())
|
||||
if !ok || stored == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if stored.GetProviderPaymentId() == "" {
|
||||
t.Fatalf("expected provider payment id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil)
|
||||
|
||||
_, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrInternal) {
|
||||
t.Fatalf("expected internal error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
SecretKey: "secret",
|
||||
StatusSuccess: "success",
|
||||
StatusProcessing: "processing",
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
store := newCardPayoutStore()
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, store, &http.Client{}, nil)
|
||||
|
||||
cb := baseCallback()
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
cb.Signature = ""
|
||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
|
||||
payload, err := json.Marshal(cb)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal callback: %v", err)
|
||||
}
|
||||
|
||||
status, err := processor.ProcessCallback(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected status ok, got %d", status)
|
||||
}
|
||||
|
||||
state, ok := store.Get(cb.Payment.ID)
|
||||
if !ok || state == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if state.GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED {
|
||||
t.Fatalf("expected processed status, got %v", state.GetStatus())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardTokenPayoutRequest_Valid(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenPayoutRequest()
|
||||
if err := validateCardTokenPayoutRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
|
||||
baseCfg := testMonetixConfig()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*mntxv1.CardTokenPayoutRequest)
|
||||
config func(monetix.Config) monetix.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerId = "" },
|
||||
expected: "missing_customer_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_ip",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerIp = "" },
|
||||
expected: "missing_customer_ip",
|
||||
},
|
||||
{
|
||||
name: "invalid_amount",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.AmountMinor = 0 },
|
||||
expected: "invalid_amount",
|
||||
},
|
||||
{
|
||||
name: "missing_currency",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "" },
|
||||
expected: "missing_currency",
|
||||
},
|
||||
{
|
||||
name: "unsupported_currency",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "EUR" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.AllowedCurrencies = []string{"USD"}
|
||||
return cfg
|
||||
},
|
||||
expected: "unsupported_currency",
|
||||
},
|
||||
{
|
||||
name: "missing_card_token",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CardToken = "" },
|
||||
expected: "missing_card_token",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_city_when_required",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
|
||||
r.CustomerCountry = "US"
|
||||
r.CustomerCity = ""
|
||||
r.CustomerAddress = "Main St"
|
||||
r.CustomerZip = "12345"
|
||||
},
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.RequireCustomerAddress = true
|
||||
return cfg
|
||||
},
|
||||
expected: "missing_customer_city",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := validCardTokenPayoutRequest()
|
||||
tc.mutate(req)
|
||||
cfg := baseCfg
|
||||
if tc.config != nil {
|
||||
cfg = tc.config(cfg)
|
||||
}
|
||||
err := validateCardTokenPayoutRequest(req, cfg)
|
||||
requireReason(t, err, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.Card = &mntxv1.CardDetails{
|
||||
Pan: "4111111111111111",
|
||||
ExpMonth: req.CardExpMonth,
|
||||
ExpYear: req.CardExpYear,
|
||||
CardHolder: req.CardHolder,
|
||||
Cvv: req.CardCvv,
|
||||
}
|
||||
req.CardPan = ""
|
||||
req.CardExpMonth = 0
|
||||
req.CardExpYear = 0
|
||||
req.CardHolder = ""
|
||||
req.CardCvv = ""
|
||||
|
||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_Expired(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
now := time.Now().UTC()
|
||||
req.CardExpMonth = uint32(now.Month())
|
||||
req.CardExpYear = uint32(now.Year() - 1)
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "expired_card")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.CardCvv = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_cvv")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.CardPan = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_card_pan")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
cfg.RequireCustomerAddress = true
|
||||
req := validCardTokenizeRequest()
|
||||
req.CustomerCountry = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_customer_country")
|
||||
}
|
||||
@@ -164,11 +164,3 @@ func statusLabel(err error) string {
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCallbackStatus(status string) string {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.ToLower(status)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
||||
@@ -17,14 +18,19 @@ func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (
|
||||
|
||||
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
||||
ref := strings.TrimSpace(req.GetPayoutRef())
|
||||
log := s.logger.Named("payout")
|
||||
log.Info("Get payout request received", zap.String("payout_ref", ref))
|
||||
if ref == "" {
|
||||
log.Warn("Get payout request missing payout_ref")
|
||||
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
||||
}
|
||||
|
||||
payout, ok := s.store.Get(ref)
|
||||
if !ok {
|
||||
log.Warn("Payout not found", zap.String("payout_ref", ref))
|
||||
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
||||
}
|
||||
|
||||
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user