Compare commits
123 Commits
SEND004
...
69fdbf4e95
| Author | SHA1 | Date | |
|---|---|---|---|
| 69fdbf4e95 | |||
|
|
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 | ||
| 97f71d125e | |||
|
|
8db2f3926c | ||
|
|
2b68b59eca | ||
| d07e64fc4f | |||
|
|
8e40e6247b | ||
| 779cb0ead9 | |||
|
|
2e0057f839 | ||
| 25080ae168 | |||
|
|
e6b001dc61 | ||
| 97d1470515 | |||
|
|
a4481fb63d | ||
| bdf766075e | |||
|
|
47899e25d4 | ||
| 4ec934c96b | |||
|
|
19df740550 | ||
| 1079ad7d0a | |||
|
|
660f689a7a | ||
|
|
8115abb569 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ untranslated.txt
|
|||||||
generate_protos.sh
|
generate_protos.sh
|
||||||
update_dep.sh
|
update_dep.sh
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.gocache/
|
||||||
|
.cache/
|
||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
@@ -49,6 +49,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/protobuf v1.36.10
|
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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/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)
|
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)
|
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||||
if calcErr != nil {
|
if calcErr != nil {
|
||||||
@@ -113,7 +109,8 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
|||||||
|
|
||||||
entrySide := mapEntrySide(rule.EntrySide)
|
entrySide := mapEntrySide(rule.EntrySide)
|
||||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
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{
|
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 {
|
if r.plans == nil {
|
||||||
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try org-specific first if provided.
|
// Try org-specific first if provided.
|
||||||
if orgID != nil && !orgID.IsZero() {
|
if orgRef != nil && !orgRef.IsZero() {
|
||||||
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
|
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||||
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||||
return plan, rule, nil
|
return plan, rule, nil
|
||||||
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
} 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
|
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) {
|
} 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
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
}
|
}
|
||||||
if !plan.GetOrganizationRef().IsZero() {
|
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||||
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
|
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||||
}
|
}
|
||||||
if rule.RuleID != "global_capture" {
|
if rule.RuleID != "global_capture" {
|
||||||
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
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)},
|
{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}}
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||||
resolver := New(store, zap.NewNop())
|
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)},
|
{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}}
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
resolver := New(store, zap.NewNop())
|
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},
|
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
orgPlan.SetOrganizationRef(org)
|
orgPlan.OrganizationRef = &org
|
||||||
|
|
||||||
globalPlan := &model.FeePlan{
|
globalPlan := &model.FeePlan{
|
||||||
Active: true,
|
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)},
|
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
p1.SetOrganizationRef(org)
|
p1.OrganizationRef = &org
|
||||||
p2 := &model.FeePlan{
|
p2 := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
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)},
|
{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}}
|
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||||
resolver := New(store, zap.NewNop())
|
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) {
|
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
var matches []*model.FeePlan
|
var matches []*model.FeePlan
|
||||||
for _, plan := range m.plans {
|
for _, plan := range m.plans {
|
||||||
if plan == nil || plan.GetOrganizationRef() != orgRef {
|
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !plan.Active {
|
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) {
|
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
var matches []*model.FeePlan
|
var matches []*model.FeePlan
|
||||||
for _, plan := range m.plans {
|
for _, plan := range m.plans {
|
||||||
if plan == nil || !plan.GetOrganizationRef().IsZero() {
|
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !plan.Active {
|
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) {
|
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()
|
start := s.clock.Now()
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
if req != nil && req.GetIntent() != nil {
|
if intent != nil {
|
||||||
trigger = req.GetIntent().GetTrigger()
|
trigger = intent.GetTrigger()
|
||||||
}
|
}
|
||||||
var fxUsed bool
|
var fxUsed bool
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
|
linesCount := 0
|
||||||
|
appliedCount := 0
|
||||||
if err == nil && resp != nil {
|
if err == nil && resp != nil {
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
linesCount = len(resp.GetLines())
|
||||||
|
appliedCount = len(resp.GetApplied())
|
||||||
}
|
}
|
||||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
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 {
|
if err = s.validateQuoteRequest(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
|
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
return nil, err
|
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) {
|
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()
|
start := s.clock.Now()
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
if req != nil && req.GetIntent() != nil {
|
if intent != nil {
|
||||||
trigger = req.GetIntent().GetTrigger()
|
trigger = intent.GetTrigger()
|
||||||
}
|
}
|
||||||
var fxUsed bool
|
var (
|
||||||
|
fxUsed bool
|
||||||
|
expiresAt time.Time
|
||||||
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
|
linesCount := 0
|
||||||
|
appliedCount := 0
|
||||||
if err == nil && resp != nil {
|
if err == nil && resp != nil {
|
||||||
fxUsed = resp.GetFxUsed() != 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))
|
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 {
|
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -134,6 +204,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
|
|
||||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
|
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -148,7 +219,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = 60000
|
ttl = 60000
|
||||||
}
|
}
|
||||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||||
|
|
||||||
payload := feeQuoteTokenPayload{
|
payload := feeQuoteTokenPayload{
|
||||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||||
@@ -159,7 +230,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
|
|
||||||
var token string
|
var token string
|
||||||
if token, err = encodeTokenPayload(payload); err != nil {
|
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")
|
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
||||||
return nil, err
|
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) {
|
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()
|
start := s.clock.Now()
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
var fxUsed bool
|
var (
|
||||||
|
fxUsed bool
|
||||||
|
resultReason string
|
||||||
|
)
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
if err == nil && resp != nil {
|
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))
|
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()) == "" {
|
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||||
|
resultReason = "missing_token"
|
||||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -202,21 +301,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
|
|
||||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||||
if decodeErr != nil {
|
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"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
|
||||||
|
|
||||||
|
if payload.Intent != nil {
|
||||||
trigger = payload.Intent.GetTrigger()
|
trigger = payload.Intent.GetTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||||
|
resultReason = "expired"
|
||||||
|
logger.Info("fee quote token expired")
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
||||||
if parseErr != nil {
|
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"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -280,6 +387,16 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
|||||||
bookedAt = intent.GetBookedAt().AsTime()
|
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
|
var orgPtr *primitive.ObjectID
|
||||||
if !orgRef.IsZero() {
|
if !orgRef.IsZero() {
|
||||||
orgPtr = &orgRef
|
orgPtr = &orgRef
|
||||||
@@ -297,7 +414,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
|||||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||||
default:
|
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")
|
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) {
|
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
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")
|
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.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
service := NewService(
|
service := NewService(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
service := NewService(
|
service := NewService(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
service := NewService(
|
service := NewService(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
result := &types.CalculationResult{
|
result := &types.CalculationResult{
|
||||||
Lines: []*feesv1.DerivedPostingLine{
|
Lines: []*feesv1.DerivedPostingLine{
|
||||||
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
fakeOracle := &oracleclient.Fake{
|
fakeOracle := &oracleclient.Fake{
|
||||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
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 {
|
if s.plan == nil {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if s.plan.GetOrganizationRef() != orgRef {
|
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if !s.plan.Active {
|
if !s.plan.Active {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,8 +27,8 @@ const (
|
|||||||
// FeePlan describes a collection of fee rules for an organisation.
|
// FeePlan describes a collection of fee rules for an organisation.
|
||||||
type FeePlan struct {
|
type FeePlan struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
|
||||||
model.Describable `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"`
|
Active bool `bson:"active" json:"active"`
|
||||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ market:
|
|||||||
- driver: CBR
|
- driver: CBR
|
||||||
settings:
|
settings:
|
||||||
base_url: "https://www.cbr.ru"
|
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:
|
pairs:
|
||||||
BINANCE:
|
BINANCE:
|
||||||
- base: "USDT"
|
- base: "USDT"
|
||||||
quote: "EUR"
|
quote: "EUR"
|
||||||
symbol: "EURUSDT"
|
symbol: "EURUSDT"
|
||||||
invert: true
|
invert: true
|
||||||
|
- base: "USD"
|
||||||
|
quote: "USDT"
|
||||||
|
symbol: "USDTUSD"
|
||||||
|
invert: true
|
||||||
- base: "UAH"
|
- base: "UAH"
|
||||||
quote: "USDT"
|
quote: "USDT"
|
||||||
symbol: "USDTUAH"
|
symbol: "USDTUAH"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // 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/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // 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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/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 {
|
func (s *Service) pollOnce(ctx context.Context) error {
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
failures := 0
|
||||||
for _, pair := range s.pairs {
|
for _, pair := range s.pairs {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := s.upsertPair(ctx, pair)
|
err := s.upsertPair(ctx, pair)
|
||||||
@@ -96,14 +97,24 @@ func (s *Service) pollOnce(ctx context.Context) error {
|
|||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
failures++
|
||||||
s.logger.Warn("Failed to ingest pair",
|
s.logger.Warn("Failed to ingest pair",
|
||||||
zap.String("symbol", pair.Symbol),
|
zap.String("symbol", pair.Symbol),
|
||||||
zap.String("source", pair.Source.String()),
|
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.Duration("elapsed", elapsed),
|
||||||
zap.Error(err),
|
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
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +126,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
|||||||
|
|
||||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return merrors.InternalWrap(err, "fetch ticker")
|
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
bid, err := parseDecimal(ticker.BidPrice)
|
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",
|
s.logger.Debug("Snapshot ingested",
|
||||||
zap.String("pair", pair.Base+"/"+pair.Quote),
|
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||||
zap.String("provider", pair.Provider),
|
zap.String("provider", pair.Provider),
|
||||||
|
zap.String("source", pair.Source.String()),
|
||||||
|
zap.String("provider_ref", snapshot.ProviderRef),
|
||||||
zap.String("bid", snapshot.Bid),
|
zap.String("bid", snapshot.Bid),
|
||||||
zap.String("ask", snapshot.Ask),
|
zap.String("ask", snapshot.Ask),
|
||||||
zap.String("mid", snapshot.Mid),
|
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
|
return nil
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
type cbrConnector struct {
|
type cbrConnector struct {
|
||||||
id mmodel.Driver
|
id mmodel.Driver
|
||||||
provider string
|
provider string
|
||||||
client *http.Client
|
http *httpClient
|
||||||
base string
|
base string
|
||||||
dailyPath string
|
dailyPath string
|
||||||
directoryPath string
|
directoryPath string
|
||||||
@@ -60,6 +60,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
|||||||
directoryPath := defaultDirectoryPath
|
directoryPath := defaultDirectoryPath
|
||||||
dailyPath := defaultDailyPath
|
dailyPath := defaultDailyPath
|
||||||
dynamicPath := defaultDynamicPath
|
dynamicPath := defaultDynamicPath
|
||||||
|
userAgent := defaultUserAgent
|
||||||
|
acceptHeader := defaultAccept
|
||||||
|
|
||||||
if settings != nil {
|
if settings != nil {
|
||||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
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) != "" {
|
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
dynamicPath = 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)
|
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||||
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||||
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
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
|
transport = customTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: requestTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
referer := parsed.String()
|
||||||
|
|
||||||
connector := &cbrConnector{
|
connector := &cbrConnector{
|
||||||
id: mmodel.DriverCBR,
|
id: mmodel.DriverCBR,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
client: &http.Client{
|
http: newHTTPClient(
|
||||||
Timeout: requestTimeout,
|
logger,
|
||||||
Transport: transport,
|
client,
|
||||||
|
httpClientOptions{
|
||||||
|
userAgent: userAgent,
|
||||||
|
accept: acceptHeader,
|
||||||
|
referer: referer,
|
||||||
},
|
},
|
||||||
|
),
|
||||||
base: strings.TrimRight(parsed.String(), "/"),
|
base: strings.TrimRight(parsed.String(), "/"),
|
||||||
dailyPath: dailyPath,
|
dailyPath: dailyPath,
|
||||||
directoryPath: directoryPath,
|
directoryPath: directoryPath,
|
||||||
@@ -161,20 +180,32 @@ func (c *cbrConnector) refreshDirectory() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return merrors.InternalWrap(err, "cbr: build directory request")
|
return merrors.InternalWrap(err, "cbr: build directory request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
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")
|
return merrors.InternalWrap(err, "cbr: directory request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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))
|
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +214,13 @@ func (c *cbrConnector) refreshDirectory() error {
|
|||||||
|
|
||||||
var directory valuteDirectory
|
var directory valuteDirectory
|
||||||
if err := decoder.Decode(&directory); err != nil {
|
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")
|
return merrors.InternalWrap(err, "cbr: decode directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
mapping, err := buildValuteMapping(directory.Items)
|
mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,23 +232,32 @@ func (c *cbrConnector) refreshDirectory() error {
|
|||||||
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
|
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
|
||||||
endpoint, err := c.buildURL(c.dailyPath, nil)
|
endpoint, err := c.buildURL(c.dailyPath, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||||
if err != nil {
|
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")
|
return "", merrors.InternalWrap(err, "cbr: build daily request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
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")
|
return "", merrors.InternalWrap(err, "cbr: daily request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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))
|
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
|
var payload dailyRates
|
||||||
if err := decoder.Decode(&payload); err != nil {
|
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")
|
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"),
|
"date_req2": date.Format("02/01/2006"),
|
||||||
"VAL_NM_RQ": valute.ID,
|
"VAL_NM_RQ": valute.ID,
|
||||||
}
|
}
|
||||||
|
dateStr := date.Format("2006-01-02")
|
||||||
endpoint, err := c.buildURL(c.dynamicPath, query)
|
endpoint, err := c.buildURL(c.dynamicPath, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", merrors.InternalWrap(err, "cbr: build historical request")
|
return "", merrors.InternalWrap(err, "cbr: build historical request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
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")
|
return "", merrors.InternalWrap(err, "cbr: historical request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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))
|
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
|
var payload dynamicRates
|
||||||
if err := decoder.Decode(&payload); err != nil {
|
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")
|
return "", merrors.InternalWrap(err, "cbr: decode historical response")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +401,7 @@ type valuteMapping struct {
|
|||||||
byID map[string]valuteInfo
|
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))
|
byISO := make(map[string]valuteInfo, len(items))
|
||||||
byID := make(map[string]valuteInfo, len(items))
|
byID := make(map[string]valuteInfo, len(items))
|
||||||
byNum := make(map[string]string, len(items))
|
byNum := make(map[string]string, len(items))
|
||||||
@@ -348,12 +412,19 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
|||||||
isoNum := strings.TrimSpace(item.ISONum)
|
isoNum := strings.TrimSpace(item.ISONum)
|
||||||
name := strings.TrimSpace(item.Name)
|
name := strings.TrimSpace(item.Name)
|
||||||
engName := strings.TrimSpace(item.EngName)
|
engName := strings.TrimSpace(item.EngName)
|
||||||
|
|
||||||
nominal, err := parseNominal(item.NominalStr)
|
nominal, err := parseNominal(item.NominalStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
|
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if id == "" || isoChar == "" {
|
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{
|
info := valuteInfo{
|
||||||
@@ -365,12 +436,60 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
|||||||
Nominal: nominal,
|
Nominal: nominal,
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
|
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
|
||||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
|
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
|
||||||
}
|
}
|
||||||
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
|
|
||||||
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
|
// 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 isoNum != "" {
|
||||||
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||||
@@ -378,6 +497,24 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
|||||||
byNum[isoNum] = id
|
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)
|
||||||
|
}
|
||||||
|
byNum[isoNum] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
|
||||||
|
|
||||||
byISO[isoChar] = info
|
byISO[isoChar] = info
|
||||||
byID[id] = 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)
|
application, err := app.New(logger, *configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("Failed to initialise application", zap.Error(err))
|
logger.Error("Failed to initialise application", zap.Error(err))
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if err := application.Run(ctx); err != nil {
|
if err := application.Run(ctx); err != nil {
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
logger.Info("FX ingestor stopped")
|
logger.Info("FX ingestor stopped")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Fatal("Ingestor terminated with error", zap.Error(err))
|
logger.Error("Ingestor terminated with error", zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("FX ingestor stopped")
|
logger.Info("FX ingestor stopped")
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // 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/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
smodel "github.com/tech/sendico/pkg/model"
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
)
|
)
|
||||||
@@ -138,11 +138,11 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
|
|||||||
Pair: qc.pair.Pair,
|
Pair: qc.pair.Pair,
|
||||||
Side: qc.sideModel,
|
Side: qc.sideModel,
|
||||||
Price: formatRat(qc.priceRounded, qc.priceScale),
|
Price: formatRat(qc.priceRounded, qc.priceScale),
|
||||||
BaseAmount: model.Money{
|
BaseAmount: smodel.Money{
|
||||||
Currency: qc.pair.Pair.Base,
|
Currency: qc.pair.Pair.Base,
|
||||||
Amount: formatRat(qc.baseRounded, qc.baseScale),
|
Amount: formatRat(qc.baseRounded, qc.baseScale),
|
||||||
},
|
},
|
||||||
QuoteAmount: model.Money{
|
QuoteAmount: smodel.Money{
|
||||||
Currency: qc.pair.Pair.Quote,
|
Currency: qc.pair.Pair.Quote,
|
||||||
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
|
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
|
||||||
},
|
},
|
||||||
@@ -170,10 +170,13 @@ func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
|
|||||||
}
|
}
|
||||||
trace := meta.GetTrace()
|
trace := meta.GetTrace()
|
||||||
qm := &model.QuoteMeta{
|
qm := &model.QuoteMeta{
|
||||||
RequestRef: deriveRequestRef(meta, trace),
|
|
||||||
TenantRef: meta.GetTenantRef(),
|
TenantRef: meta.GetTenantRef(),
|
||||||
TraceRef: deriveTraceRef(meta, trace),
|
}
|
||||||
IdempotencyKey: deriveIdempotencyKey(meta, trace),
|
|
||||||
|
if trace != nil {
|
||||||
|
qm.RequestRef = trace.GetRequestRef()
|
||||||
|
qm.TraceRef = trace.GetTraceRef()
|
||||||
|
qm.IdempotencyKey = trace.GetIdempotencyKey()
|
||||||
}
|
}
|
||||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||||
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
|
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
|
||||||
@@ -200,24 +203,3 @@ func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
|
|||||||
}
|
}
|
||||||
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
|
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
|
||||||
if trace != nil && trace.GetRequestRef() != "" {
|
|
||||||
return trace.GetRequestRef()
|
|
||||||
}
|
|
||||||
return meta.GetRequestRef()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
|
||||||
if trace != nil && trace.GetTraceRef() != "" {
|
|
||||||
return trace.GetTraceRef()
|
|
||||||
}
|
|
||||||
return meta.GetTraceRef()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
|
||||||
if trace != nil && trace.GetIdempotencyKey() != "" {
|
|
||||||
return trace.GetIdempotencyKey()
|
|
||||||
}
|
|
||||||
return meta.GetIdempotencyKey()
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package oracle
|
|||||||
import (
|
import (
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
"github.com/tech/sendico/pkg/decimal"
|
"github.com/tech/sendico/pkg/decimal"
|
||||||
@@ -61,7 +60,3 @@ func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
|
|||||||
|
|
||||||
return ratFromString(priceStr)
|
return ratFromString(priceStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func timeFromUnixMilli(ms int64) time.Time {
|
|
||||||
return time.Unix(0, ms*int64(time.Millisecond))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -101,22 +101,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
req = &oraclev1.GetQuoteRequest{}
|
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 {
|
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
|
||||||
|
logger.Warn("GetQuote invalid: side missing")
|
||||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
||||||
}
|
}
|
||||||
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
|
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)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
|
||||||
}
|
}
|
||||||
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
||||||
|
logger.Warn("GetQuote invalid: amount missing")
|
||||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
|
||||||
}
|
}
|
||||||
if err := s.pingStorage(ctx); err != nil {
|
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)
|
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
pairMsg := req.GetPair()
|
pairMsg := req.GetPair()
|
||||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
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)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||||
}
|
}
|
||||||
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
||||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||||
default:
|
default:
|
||||||
|
logger.Warn("GetQuote failed to load pair", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, 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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||||
default:
|
default:
|
||||||
|
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
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 {
|
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||||
age := now.UnixMilli() - rate.AsOfUnixMs
|
age := now.UnixMilli() - rate.AsOfUnixMs
|
||||||
if age > int64(maxAge) {
|
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"))
|
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)
|
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Warn("GetQuote invalid input", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetBaseAmount() != nil {
|
if req.GetBaseAmount() != nil {
|
||||||
if err := comp.withBaseInput(req.GetBaseAmount()); err != 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)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
} else if req.GetQuoteAmount() != nil {
|
} else if req.GetQuoteAmount() != nil {
|
||||||
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != 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)
|
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := comp.compute(); err != nil {
|
if err := comp.compute(); err != nil {
|
||||||
|
logger.Warn("GetQuote computation failed", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, 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 {
|
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrDataConflict):
|
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)
|
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
default:
|
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)
|
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{
|
resp := &oraclev1.GetQuoteResponse{
|
||||||
@@ -214,18 +229,24 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
req = &oraclev1.ValidateQuoteRequest{}
|
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() == "" {
|
if req.GetQuoteRef() == "" {
|
||||||
|
logger.Warn("ValidateQuote invalid: quote_ref missing")
|
||||||
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||||
}
|
}
|
||||||
if err := s.pingStorage(ctx); err != nil {
|
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)
|
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
|
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
|
||||||
resp := &oraclev1.ValidateQuoteResponse{
|
resp := &oraclev1.ValidateQuoteResponse{
|
||||||
Meta: buildResponseMeta(req.GetMeta()),
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
Quote: nil,
|
Quote: nil,
|
||||||
@@ -234,6 +255,7 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
|||||||
}
|
}
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
default:
|
default:
|
||||||
|
logger.Warn("ValidateQuote failed", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, 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,
|
Valid: valid,
|
||||||
Reason: reason,
|
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)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,29 +289,43 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
req = &oraclev1.ConsumeQuoteRequest{}
|
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() == "" {
|
if req.GetQuoteRef() == "" {
|
||||||
|
logger.Warn("ConsumeQuote invalid: quote_ref missing")
|
||||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||||
}
|
}
|
||||||
if req.GetLedgerTxnRef() == "" {
|
if req.GetLedgerTxnRef() == "" {
|
||||||
|
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
|
||||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
|
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
|
||||||
}
|
}
|
||||||
if err := s.pingStorage(ctx); err != nil {
|
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)
|
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
|
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, storage.ErrQuoteExpired):
|
case errors.Is(err, storage.ErrQuoteExpired):
|
||||||
|
logger.Warn("ConsumeQuote failed: expired")
|
||||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
||||||
case errors.Is(err, storage.ErrQuoteConsumed):
|
case errors.Is(err, storage.ErrQuoteConsumed):
|
||||||
|
logger.Warn("ConsumeQuote failed: already consumed")
|
||||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
||||||
case errors.Is(err, storage.ErrQuoteNotFirm):
|
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)
|
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
logger.Warn("ConsumeQuote failed: quote not found")
|
||||||
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||||
default:
|
default:
|
||||||
|
logger.Warn("ConsumeQuote failed", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, 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,
|
Consumed: true,
|
||||||
Reason: "consumed",
|
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)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,13 +343,21 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
req = &oraclev1.LatestRateRequest{}
|
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 {
|
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)
|
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
pairMsg := req.GetPair()
|
pairMsg := req.GetPair()
|
||||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
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)
|
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||||
}
|
}
|
||||||
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
logger.Warn("LatestRate pair not found")
|
||||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
default:
|
default:
|
||||||
|
logger.Warn("LatestRate failed to load pair", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, 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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
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)
|
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||||
default:
|
default:
|
||||||
|
logger.Warn("LatestRate failed", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, 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()),
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
Rate: rateModelToProto(rate),
|
Rate: rateModelToProto(rate),
|
||||||
}
|
}
|
||||||
|
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,13 +406,15 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
req = &oraclev1.ListPairsRequest{}
|
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 {
|
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)
|
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
pairs, err := s.storage.Pairs().ListEnabled(ctx)
|
pairs, err := s.storage.Pairs().ListEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Warn("ListPairs failed", zap.Error(err))
|
||||||
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||||
}
|
}
|
||||||
result := make([]*oraclev1.PairMeta, 0, len(pairs))
|
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()),
|
Meta: buildResponseMeta(req.GetMeta()),
|
||||||
Pairs: result,
|
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)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/fx/storage"
|
"github.com/tech/sendico/fx/storage"
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
smodel "github.com/tech/sendico/pkg/model"
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
@@ -381,8 +382,8 @@ func TestServiceValidateQuote(t *testing.T) {
|
|||||||
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
|
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||||
Side: model.QuoteSideBuyBaseSellQuote,
|
Side: model.QuoteSideBuyBaseSellQuote,
|
||||||
Price: "1.10",
|
Price: "1.10",
|
||||||
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
|
BaseAmount: smodel.Money{Currency: "USD", Amount: "100"},
|
||||||
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
|
QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"},
|
||||||
ExpiresAtUnixMs: now.UnixMilli(),
|
ExpiresAtUnixMs: now.UnixMilli(),
|
||||||
Status: model.QuoteStatusIssued,
|
Status: model.QuoteStatusIssued,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
smodel "github.com/tech/sendico/pkg/model"
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,18 +15,11 @@ func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
|
|||||||
if meta == nil {
|
if meta == nil {
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
resp.RequestRef = meta.GetRequestRef()
|
|
||||||
resp.TraceRef = meta.GetTraceRef()
|
|
||||||
|
|
||||||
trace := meta.GetTrace()
|
trace := meta.GetTrace()
|
||||||
if trace == nil {
|
if trace != nil {
|
||||||
trace = &tracev1.TraceContext{
|
|
||||||
RequestRef: meta.GetRequestRef(),
|
|
||||||
IdempotencyKey: meta.GetIdempotencyKey(),
|
|
||||||
TraceRef: meta.GetTraceRef(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.Trace = trace
|
resp.Trace = trace
|
||||||
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +42,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func moneyModelToProto(m *model.Money) *moneyv1.Money {
|
func moneyModelToProto(m *smodel.Money) *moneyv1.Money {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,5 @@ require (
|
|||||||
golang.org/x/crypto v0.46.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.32.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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Quote represents a firm or indicative quote persisted by the oracle.
|
// Quote represents a firm or indicative quote persisted by the oracle.
|
||||||
@@ -16,8 +17,8 @@ type Quote struct {
|
|||||||
Pair CurrencyPair `bson:"pair" json:"pair"`
|
Pair CurrencyPair `bson:"pair" json:"pair"`
|
||||||
Side QuoteSide `bson:"side" json:"side"`
|
Side QuoteSide `bson:"side" json:"side"`
|
||||||
Price string `bson:"price" json:"price"`
|
Price string `bson:"price" json:"price"`
|
||||||
BaseAmount Money `bson:"baseAmount" json:"baseAmount"`
|
BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"`
|
||||||
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"`
|
QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"`
|
||||||
AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
|
AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
|
||||||
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
|
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
|
||||||
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ type CurrencyPair struct {
|
|||||||
Quote string `bson:"quote" json:"quote"`
|
Quote string `bson:"quote" json:"quote"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Money represents an exact decimal amount with its currency.
|
|
||||||
type Money struct {
|
|
||||||
Currency string `bson:"currency" json:"currency"`
|
|
||||||
Amount string `bson:"amount" json:"amount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuoteMeta carries request-scoped metadata associated with a quote.
|
// QuoteMeta carries request-scoped metadata associated with a quote.
|
||||||
type QuoteMeta struct {
|
type QuoteMeta struct {
|
||||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type Client interface {
|
|||||||
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ type grpcGatewayClient interface {
|
|||||||
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainGatewayClient struct {
|
type chainGatewayClient struct {
|
||||||
@@ -139,6 +143,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain
|
|||||||
return c.client.EstimateTransferFee(ctx, req)
|
return c.client.EstimateTransferFee(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.ComputeGasTopUp(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.EnsureGasTopUp(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
timeout := c.cfg.CallTimeout
|
timeout := c.cfg.CallTimeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type Fake struct {
|
|||||||
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||||
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||||
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||||
|
ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||||
|
EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||||
CloseFn func() error
|
CloseFn func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
|
|||||||
return &chainv1.EstimateTransferFeeResponse{}, nil
|
return &chainv1.EstimateTransferFeeResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
if f.ComputeGasTopUpFn != nil {
|
||||||
|
return f.ComputeGasTopUpFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &chainv1.ComputeGasTopUpResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
if f.EnsureGasTopUpFn != nil {
|
||||||
|
return f.EnsureGasTopUpFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) Close() error {
|
func (f *Fake) Close() error {
|
||||||
if f.CloseFn != nil {
|
if f.CloseFn != nil {
|
||||||
return f.CloseFn()
|
return f.CloseFn()
|
||||||
|
|||||||
@@ -34,16 +34,23 @@ messaging:
|
|||||||
reconnect_wait: 5
|
reconnect_wait: 5
|
||||||
|
|
||||||
chains:
|
chains:
|
||||||
- name: arbitrum_one
|
- name: tron_mainnet
|
||||||
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
chain_id: 728126428 # 0x2b6653dc
|
||||||
|
native_token: TRX
|
||||||
|
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
||||||
|
gas_topup_policy:
|
||||||
|
buffer_percent: 0.10
|
||||||
|
min_native_balance_trx: 10
|
||||||
|
rounding_unit_trx: 1
|
||||||
|
max_topup_trx: 100
|
||||||
tokens:
|
tokens:
|
||||||
- symbol: USDC
|
|
||||||
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
|
|
||||||
- symbol: USDT
|
- symbol: USDT
|
||||||
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
|
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
- symbol: USDC
|
||||||
|
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
|
||||||
|
|
||||||
service_wallet:
|
service_wallet:
|
||||||
chain: arbitrum_one
|
chain: tron_mainnet
|
||||||
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
||||||
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
||||||
|
|
||||||
@@ -58,3 +65,4 @@ key_management:
|
|||||||
|
|
||||||
cache:
|
cache:
|
||||||
wallet_balance_ttl_seconds: 120
|
wallet_balance_ttl_seconds: 120
|
||||||
|
rpc_request_timeout_seconds: 15
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
@@ -60,7 +60,7 @@ require (
|
|||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-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/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM=
|
||||||
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-20251225023818-8886bb81c549/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package serverimp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
||||||
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
||||||
@@ -30,6 +34,8 @@ type Imp struct {
|
|||||||
|
|
||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
|
||||||
|
rpcClients *rpcclient.Clients
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -46,6 +52,7 @@ type chainConfig struct {
|
|||||||
ChainID uint64 `yaml:"chain_id"`
|
ChainID uint64 `yaml:"chain_id"`
|
||||||
NativeToken string `yaml:"native_token"`
|
NativeToken string `yaml:"native_token"`
|
||||||
Tokens []tokenConfig `yaml:"tokens"`
|
Tokens []tokenConfig `yaml:"tokens"`
|
||||||
|
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceWalletConfig struct {
|
type serviceWalletConfig struct {
|
||||||
@@ -61,6 +68,19 @@ type tokenConfig struct {
|
|||||||
ContractEnv string `yaml:"contract_env"`
|
ContractEnv string `yaml:"contract_env"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gasTopUpPolicyConfig struct {
|
||||||
|
gasTopUpRuleConfig `yaml:",inline"`
|
||||||
|
Native *gasTopUpRuleConfig `yaml:"native"`
|
||||||
|
Contract *gasTopUpRuleConfig `yaml:"contract"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gasTopUpRuleConfig struct {
|
||||||
|
BufferPercent float64 `yaml:"buffer_percent"`
|
||||||
|
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
|
||||||
|
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
|
||||||
|
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
|
||||||
|
}
|
||||||
|
|
||||||
// Create initialises the chain gateway server implementation.
|
// Create initialises the chain gateway server implementation.
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
return &Imp{
|
return &Imp{
|
||||||
@@ -84,6 +104,9 @@ func (i *Imp) Shutdown() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
|
if i.rpcClients != nil {
|
||||||
|
i.rpcClients.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
@@ -98,20 +121,34 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cl := i.logger.Named("config")
|
cl := i.logger.Named("config")
|
||||||
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("invalid chain network configuration", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.rpcClients = rpcClients
|
||||||
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
||||||
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
|
|
||||||
opts := []gatewayservice.Option{
|
opts := []gatewayservice.Option{
|
||||||
gatewayservice.WithNetworks(networkConfigs),
|
gatewayservice.WithNetworks(networkConfigs),
|
||||||
gatewayservice.WithServiceWallet(walletConfig),
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
gatewayservice.WithKeyManager(keyManager),
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
gatewayservice.WithTransferExecutor(executor),
|
gatewayservice.WithRPCClients(rpcClients),
|
||||||
|
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||||
gatewayservice.WithSettings(cfg.Settings),
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||||
@@ -157,7 +194,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
|
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
|
||||||
result := make([]gatewayshared.Network, 0, len(chains))
|
result := make([]gatewayshared.Network, 0, len(chains))
|
||||||
for _, chain := range chains {
|
for _, chain := range chains {
|
||||||
if strings.TrimSpace(chain.Name) == "" {
|
if strings.TrimSpace(chain.Name) == "" {
|
||||||
@@ -166,7 +203,8 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
|||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
|
||||||
}
|
}
|
||||||
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
||||||
for _, token := range chain.Tokens {
|
for _, token := range chain.Tokens {
|
||||||
@@ -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{
|
result = append(result, gatewayshared.Network{
|
||||||
Name: chain.Name,
|
Name: chain.Name,
|
||||||
RPCURL: rpcURL,
|
RPCURL: rpcURL,
|
||||||
ChainID: chain.ChainID,
|
ChainID: chain.ChainID,
|
||||||
NativeToken: chain.NativeToken,
|
NativeToken: chain.NativeToken,
|
||||||
TokenConfigs: contracts,
|
TokenConfigs: contracts,
|
||||||
|
GasTopUpPolicy: gasPolicy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !defaultSet {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := &gatewayshared.GasTopUpPolicy{
|
||||||
|
Default: defaultRule,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Native != nil {
|
||||||
|
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
policy.Native = &rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Contract != nil {
|
||||||
|
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
policy.Contract = &rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
|
||||||
|
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, false, nil
|
||||||
|
}
|
||||||
|
if cfg.BufferPercent < 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.MinNativeBalanceTRX < 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.RoundingUnitTRX <= 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
|
||||||
|
}
|
||||||
|
if cfg.MaxTopUpTRX <= 0 {
|
||||||
|
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
|
||||||
|
}
|
||||||
|
return gatewayshared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
|
||||||
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Registry struct {
|
|||||||
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
||||||
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
||||||
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
||||||
|
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
|
||||||
|
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistryDeps struct {
|
type RegistryDeps struct {
|
||||||
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
|
|||||||
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
||||||
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
||||||
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
||||||
|
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
|
||||||
|
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package transfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
@@ -11,9 +14,11 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,22 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
deps.Logger.Warn("destination external address missing")
|
deps.Logger.Warn("destination external address missing")
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
}
|
}
|
||||||
|
if deps.Drivers == nil {
|
||||||
|
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
|
||||||
|
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
normalized, err := chainDriver.NormalizeAddress(external)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("invalid external address", zap.Error(err))
|
||||||
|
return model.TransferDestination{}, err
|
||||||
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
ExternalAddress: strings.ToLower(external),
|
ExternalAddress: normalized,
|
||||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
|
func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,12 @@ package transfer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -63,11 +53,20 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
}
|
}
|
||||||
|
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
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)
|
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,170 +78,30 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
|
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
|
driverDeps := driver.Deps{
|
||||||
|
Logger: c.deps.Logger,
|
||||||
|
Registry: c.deps.Networks,
|
||||||
|
RPCTimeout: c.deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
||||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contextLabel := "erc20_transfer"
|
||||||
|
if strings.TrimSpace(sourceWallet.ContractAddress) == "" {
|
||||||
|
contextLabel = "native_transfer"
|
||||||
|
}
|
||||||
resp := &chainv1.EstimateTransferFeeResponse{
|
resp := &chainv1.EstimateTransferFeeResponse{
|
||||||
NetworkFee: feeMoney,
|
NetworkFee: feeMoney,
|
||||||
EstimationContext: "erc20_transfer",
|
EstimationContext: contextLabel,
|
||||||
}
|
}
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
|
||||||
if rpcURL == "" {
|
|
||||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(wallet.ContractAddress) == "" {
|
|
||||||
return nil, merrors.NotImplemented("native token transfers not supported")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.ContractAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.DepositAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(destination) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid destination address")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
|
||||||
toAddr := common.HexToAddress(destination)
|
|
||||||
fromAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("failed to read token decimals", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
callMsg := ethereum.CallMsg{
|
|
||||||
From: fromAddr,
|
|
||||||
To: &tokenAddr,
|
|
||||||
GasPrice: gasPrice,
|
|
||||||
Data: input,
|
|
||||||
}
|
|
||||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
|
||||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
|
||||||
|
|
||||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
|
||||||
if currency == "" {
|
|
||||||
currency = strings.ToUpper(network.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: currency,
|
|
||||||
Amount: feeDec.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
callData, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{
|
|
||||||
To: &token,
|
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", output)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
|
|
||||||
}
|
|
||||||
if len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("decimals call returned no data")
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, merrors.Internal("decimals call returned unexpected type")
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
|
||||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
|
||||||
}
|
|
||||||
if value.IsNegative() {
|
|
||||||
return nil, merrors.InvalidArgument("amount must be positive")
|
|
||||||
}
|
|
||||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
|
||||||
scaled := value.Mul(multiplier)
|
|
||||||
if !scaled.Equal(scaled.Truncate(0)) {
|
|
||||||
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
|
||||||
}
|
|
||||||
return scaled.BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20TransferABI = `
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [],
|
|
||||||
"name": "decimals",
|
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": false,
|
|
||||||
"inputs": [
|
|
||||||
{ "name": "_to", "type": "address" },
|
|
||||||
{ "name": "_value", "type": "uint256" }
|
|
||||||
],
|
|
||||||
"name": "transfer",
|
|
||||||
"outputs": [{ "name": "", "type": "bool" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "nonpayable",
|
|
||||||
"type": "function"
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type computeGasTopUpCommand struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
|
||||||
|
return &computeGasTopUpCommand{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
|
||||||
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
|
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
c.deps.Logger.Warn("nil request")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||||
|
if walletRef == "" {
|
||||||
|
c.deps.Logger.Warn("wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
|
}
|
||||||
|
estimatedFee := req.GetEstimatedTotalFee()
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
c.deps.Logger.Warn("estimated fee missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||||
|
|
||||||
|
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
|
||||||
|
TopupAmount: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ensureGasTopUpCommand struct {
|
||||||
|
deps Deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
|
||||||
|
return &ensureGasTopUpCommand{deps: deps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
|
||||||
|
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||||
|
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||||
|
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
c.deps.Logger.Warn("nil request")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
c.deps.Logger.Warn("idempotency key missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||||
|
}
|
||||||
|
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
if organizationRef == "" {
|
||||||
|
c.deps.Logger.Warn("organization ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||||
|
}
|
||||||
|
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
if sourceWalletRef == "" {
|
||||||
|
c.deps.Logger.Warn("source wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
|
||||||
|
if targetWalletRef == "" {
|
||||||
|
c.deps.Logger.Warn("target wallet ref missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
|
||||||
|
}
|
||||||
|
estimatedFee := req.GetEstimatedTotalFee()
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
c.deps.Logger.Warn("estimated fee missing")
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||||
|
|
||||||
|
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
|
||||||
|
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: nil,
|
||||||
|
CapHit: capHit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
submitReq := &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
OrganizationRef: organizationRef,
|
||||||
|
SourceWalletRef: sourceWalletRef,
|
||||||
|
Destination: &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
|
||||||
|
},
|
||||||
|
Amount: topUp,
|
||||||
|
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||||
|
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||||
|
}
|
||||||
|
|
||||||
|
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
|
||||||
|
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
submitResp, err := submitResponder(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
Transfer: submitResp.GetTransfer(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
|
||||||
|
walletRef = strings.TrimSpace(walletRef)
|
||||||
|
estimatedFee = shared.CloneMoney(estimatedFee)
|
||||||
|
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
|
||||||
|
networkCfg, ok := deps.Networks.Network(networkKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(networkKey, "tron") {
|
||||||
|
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if networkCfg.GasTopUpPolicy != nil {
|
||||||
|
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, capHit, nil, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return topUp, false, nil, nativeBalance, walletModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
if walletModel == nil {
|
||||||
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
walletDeps := wallet.Deps{
|
||||||
|
Logger: deps.Logger.Named("wallet"),
|
||||||
|
Drivers: deps.Drivers,
|
||||||
|
Networks: deps.Networks,
|
||||||
|
KeyManager: nil,
|
||||||
|
Storage: deps.Storage,
|
||||||
|
Clock: deps.Clock,
|
||||||
|
BalanceCacheTTL: 0,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
EnsureRepository: deps.EnsureRepository,
|
||||||
|
}
|
||||||
|
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("native balance is unavailable")
|
||||||
|
}
|
||||||
|
return nativeBalance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("native balance is required")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
|
||||||
|
return nil, merrors.InvalidArgument("native balance currency mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
required := estimated.Sub(current)
|
||||||
|
if !required.IsPositive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
|
||||||
|
Amount: required.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
|
||||||
|
if logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("wallet_ref", walletRef),
|
||||||
|
zap.String("estimated_total_fee", amountString(estimatedFee)),
|
||||||
|
zap.String("current_native_balance", amountString(nativeBalance)),
|
||||||
|
zap.String("topup_amount", amountString(topUp)),
|
||||||
|
zap.Bool("cap_hit", capHit),
|
||||||
|
}
|
||||||
|
if walletModel != nil {
|
||||||
|
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
|
||||||
|
}
|
||||||
|
if decision != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
|
||||||
|
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
|
||||||
|
zap.String("required_trx", decision.RequiredTRX.String()),
|
||||||
|
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
|
||||||
|
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
|
||||||
|
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
|
||||||
|
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
|
||||||
|
zap.String("topup_trx", decision.TopUpTRX.String()),
|
||||||
|
zap.String("operation_type", decision.OperationType),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.Info("gas top-up decision", fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountString(m *moneyv1.Money) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
if amount == "" && currency == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
if amount == "" {
|
||||||
|
return currency
|
||||||
|
}
|
||||||
|
return amount + " " + currency
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||||
}
|
}
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
|||||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
|
||||||
if chainErr != nil {
|
if chainErr != nil {
|
||||||
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||||
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||||
@@ -74,37 +74,47 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
|||||||
}
|
}
|
||||||
|
|
||||||
calculatedAt := c.now()
|
calculatedAt := c.now()
|
||||||
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
|
||||||
|
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||||
Balance: onChainBalanceToProto(balance, calculatedAt),
|
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||||
if balance == nil {
|
if balance == nil && native == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
zero := zeroMoney(balance.Currency)
|
currency := ""
|
||||||
|
if balance != nil {
|
||||||
|
currency = balance.Currency
|
||||||
|
}
|
||||||
|
zero := zeroMoney(currency)
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: balance,
|
Available: balance,
|
||||||
|
NativeAvailable: native,
|
||||||
PendingInbound: zero,
|
PendingInbound: zero,
|
||||||
PendingOutbound: zero,
|
PendingOutbound: zero,
|
||||||
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
|
||||||
if available == nil {
|
if available == nil && nativeAvailable == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
record := &model.WalletBalance{
|
record := &model.WalletBalance{
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
Available: shared.CloneMoney(available),
|
Available: shared.CloneMoney(available),
|
||||||
PendingInbound: zeroMoney(available.Currency),
|
NativeAvailable: shared.CloneMoney(nativeAvailable),
|
||||||
PendingOutbound: zeroMoney(available.Currency),
|
|
||||||
CalculatedAt: calculatedAt,
|
CalculatedAt: calculatedAt,
|
||||||
}
|
}
|
||||||
|
currency := ""
|
||||||
|
if available != nil {
|
||||||
|
currency = available.Currency
|
||||||
|
}
|
||||||
|
record.PendingInbound = zeroMoney(currency)
|
||||||
|
record.PendingOutbound = zeroMoney(currency)
|
||||||
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
||||||
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -59,11 +60,20 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
networkCfg, ok := c.deps.Networks[chainKey]
|
networkCfg, ok := c.deps.Networks.Network(chainKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
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()))
|
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
if tokenSymbol == "" {
|
if tokenSymbol == "" {
|
||||||
@@ -72,12 +82,14 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
}
|
}
|
||||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||||
if contractAddress == "" {
|
if contractAddress == "" {
|
||||||
|
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
|
||||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||||
if contractAddress == "" {
|
if contractAddress == "" {
|
||||||
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
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"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
walletRef := shared.GenerateWalletRef()
|
walletRef := shared.GenerateWalletRef()
|
||||||
if c.deps.KeyManager == nil {
|
if c.deps.KeyManager == nil {
|
||||||
@@ -94,8 +106,37 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
c.deps.Logger.Warn("key manager returned empty address")
|
c.deps.Logger.Warn("key manager returned empty address")
|
||||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||||
}
|
}
|
||||||
|
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := shared.CloneMetadata(req.GetMetadata())
|
||||||
|
desc := req.GetDescribable()
|
||||||
|
name := strings.TrimSpace(desc.GetName())
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(metadata["name"])
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if desc != nil && desc.Description != nil {
|
||||||
|
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description == nil {
|
||||||
|
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = walletRef
|
||||||
|
}
|
||||||
|
|
||||||
wallet := &model.ManagedWallet{
|
wallet := &model.ManagedWallet{
|
||||||
|
Describable: pkgmodel.Describable{
|
||||||
|
Name: name,
|
||||||
|
},
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
OrganizationRef: organizationRef,
|
OrganizationRef: organizationRef,
|
||||||
@@ -103,10 +144,13 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
Network: chainKey,
|
Network: chainKey,
|
||||||
TokenSymbol: tokenSymbol,
|
TokenSymbol: tokenSymbol,
|
||||||
ContractAddress: contractAddress,
|
ContractAddress: contractAddress,
|
||||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
DepositAddress: depositAddress,
|
||||||
KeyReference: keyInfo.KeyID,
|
KeyReference: keyInfo.KeyID,
|
||||||
Status: model.ManagedWalletStatusActive,
|
Status: model.ManagedWalletStatusActive,
|
||||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
if description != nil {
|
||||||
|
wallet.Describable.Description = description
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -13,17 +14,20 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
KeyManager keymanager.Manager
|
KeyManager keymanager.Manager
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
BalanceCacheTTL time.Duration
|
BalanceCacheTTL time.Duration
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Deps) WithLogger(name string) Deps {
|
func (d Deps) WithLogger(name string) Deps {
|
||||||
if d.Logger != nil {
|
if d.Logger == nil {
|
||||||
d.Logger = d.Logger.Named(name)
|
panic("wallet deps: logger is required")
|
||||||
}
|
}
|
||||||
|
d.Logger = d.Logger.Named(name)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,123 +2,61 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math/big"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
|
||||||
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
|
logger := deps.Logger
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
if wallet == nil {
|
||||||
if rpcURL == "" {
|
return nil, nil, merrors.InvalidArgument("wallet is required")
|
||||||
return nil, merrors.Internal("network rpc url is not configured")
|
|
||||||
}
|
}
|
||||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
if deps.Networks == nil {
|
||||||
if contract == "" || !common.IsHexAddress(contract) {
|
return nil, nil, merrors.Internal("rpc clients not initialised")
|
||||||
return nil, merrors.InvalidArgument("invalid contract address")
|
|
||||||
}
|
}
|
||||||
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
|
if deps.Drivers == nil {
|
||||||
return nil, merrors.InvalidArgument("invalid wallet address")
|
return nil, nil, merrors.Internal("chain drivers not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
||||||
if err != nil {
|
network, ok := deps.Networks.Network(networkKey)
|
||||||
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(contract)
|
|
||||||
walletAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
|
||||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
data, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals")
|
|
||||||
}
|
|
||||||
if val, ok := values[0].(uint8); ok {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
return 0, merrors.Internal("decimals returned unexpected type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
|
|
||||||
data, err := tokenABI.Pack("balanceOf", wallet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("balanceOf", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return nil, merrors.Internal("failed to unpack balanceOf")
|
|
||||||
}
|
|
||||||
raw, ok := values[0].(*big.Int)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, merrors.Internal("balanceOf returned unexpected type")
|
logger.Warn("Requested network is not configured",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", networkKey),
|
||||||
|
)
|
||||||
|
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||||
}
|
}
|
||||||
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20ABIJSON = `
|
chainDriver, err := deps.Drivers.Driver(networkKey)
|
||||||
[
|
if err != nil {
|
||||||
{
|
logger.Warn("Chain driver not configured",
|
||||||
"constant": true,
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
"inputs": [],
|
zap.String("network", networkKey),
|
||||||
"name": "decimals",
|
zap.Error(err),
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
)
|
||||||
"payable": false,
|
return nil, nil, merrors.InvalidArgument("unsupported chain")
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [{ "name": "_owner", "type": "address" }],
|
|
||||||
"name": "balanceOf",
|
|
||||||
"outputs": [{ "name": "balance", "type": "uint256" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
}
|
}
|
||||||
]`
|
|
||||||
|
driverDeps := driver.Deps{
|
||||||
|
Logger: deps.Logger,
|
||||||
|
Registry: deps.Networks,
|
||||||
|
KeyManager: deps.KeyManager,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tokenBalance, nativeBalance, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package wallet
|
package wallet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
TokenSymbol: wallet.TokenSymbol,
|
TokenSymbol: wallet.TokenSymbol,
|
||||||
ContractAddress: wallet.ContractAddress,
|
ContractAddress: wallet.ContractAddress,
|
||||||
}
|
}
|
||||||
|
name := strings.TrimSpace(wallet.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(wallet.Metadata["name"])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = wallet.WalletRef
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
switch {
|
||||||
|
case wallet.Description != nil:
|
||||||
|
description = strings.TrimSpace(*wallet.Description)
|
||||||
|
default:
|
||||||
|
description = strings.TrimSpace(wallet.Metadata["description"])
|
||||||
|
}
|
||||||
|
desc := &describablev1.Describable{Name: name}
|
||||||
|
if description != "" {
|
||||||
|
desc.Description = &description
|
||||||
|
}
|
||||||
|
|
||||||
return &chainv1.ManagedWallet{
|
return &chainv1.ManagedWallet{
|
||||||
WalletRef: wallet.WalletRef,
|
WalletRef: wallet.WalletRef,
|
||||||
OrganizationRef: wallet.OrganizationRef,
|
OrganizationRef: wallet.OrganizationRef,
|
||||||
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
Metadata: shared.CloneMetadata(wallet.Metadata),
|
Metadata: shared.CloneMetadata(wallet.Metadata),
|
||||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||||
|
Describable: desc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
|
|||||||
}
|
}
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: shared.CloneMoney(balance.Available),
|
Available: shared.CloneMoney(balance.Available),
|
||||||
|
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
|
||||||
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
||||||
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
||||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package arbitrum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Arbitrum-specific behavior using the shared EVM logic.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("arbitrum")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "arbitrum"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("format address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("normalize address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("native balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("native balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("estimate fee failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("estimate fee result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
d.logger.Debug("submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("submit transfer failed",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("await confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("await confirmation failed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
34
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package driver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps bundles dependencies shared across chain drivers.
|
||||||
|
type Deps struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
Registry *rpcclient.Registry
|
||||||
|
KeyManager keymanager.Manager
|
||||||
|
RPCTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver defines chain-specific behavior for wallet and transfer operations.
|
||||||
|
type Driver interface {
|
||||||
|
Name() string
|
||||||
|
FormatAddress(address string) (string, error)
|
||||||
|
NormalizeAddress(address string) (string, error)
|
||||||
|
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||||
|
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||||
|
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
|
||||||
|
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
|
||||||
|
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver implements Ethereum-specific behavior using the shared EVM logic.
|
||||||
|
type Driver struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger) *Driver {
|
||||||
|
return &Driver{logger: logger.Named("ethereum")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Name() string {
|
||||||
|
return "ethereum"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("format address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||||
|
d.logger.Debug("normalize address", zap.String("address", address))
|
||||||
|
normalized, err := evm.NormalizeAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||||
|
}
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("native balance request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("native balance failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("native balance result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
d.logger.Debug("estimate fee request",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("estimate fee failed",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if result != nil {
|
||||||
|
d.logger.Debug("estimate fee result",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("amount", result.Amount),
|
||||||
|
zap.String("currency", result.Currency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||||
|
d.logger.Debug("submit transfer request",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("destination", destination),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("submit transfer failed",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
d.logger.Debug("submit transfer result",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return txHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
|
d.logger.Debug("await confirmation",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
)
|
||||||
|
driverDeps := deps
|
||||||
|
driverDeps.Logger = d.logger
|
||||||
|
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Warn("await confirmation failed",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
} else if receipt != nil {
|
||||||
|
d.logger.Debug("await confirmation result",
|
||||||
|
zap.String("tx_hash", txHash),
|
||||||
|
zap.String("network", network.Name),
|
||||||
|
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||||
|
zap.Uint64("status", receipt.Status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return receipt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
680
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
680
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
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/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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpcClient, err := registry.RPCClient(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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: &toAddr,
|
||||||
|
GasPrice: gasPrice,
|
||||||
|
Value: amountBase,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: nativeCurrency(network),
|
||||||
|
Amount: feeDec.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !common.IsHexAddress(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 {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
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 := client.EstimateGas(ctx, 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 := client.EstimateGas(ctx, 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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
223
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
223
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
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/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")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ driver.Driver = (*Driver)(nil)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
|
||||||
|
|
||||||
|
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
|
||||||
|
type GasTopUpDecision struct {
|
||||||
|
CurrentBalanceTRX decimal.Decimal
|
||||||
|
EstimatedFeeTRX decimal.Decimal
|
||||||
|
RequiredTRX decimal.Decimal
|
||||||
|
BufferedRequiredTRX decimal.Decimal
|
||||||
|
MinBalanceTopUpTRX decimal.Decimal
|
||||||
|
RawTopUpTRX decimal.Decimal
|
||||||
|
RoundedTopUpTRX decimal.Decimal
|
||||||
|
TopUpTRX decimal.Decimal
|
||||||
|
CapHit bool
|
||||||
|
OperationType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
|
||||||
|
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
|
||||||
|
decision := GasTopUpDecision{}
|
||||||
|
if wallet == nil {
|
||||||
|
return nil, decision, merrors.InvalidArgument("wallet is required")
|
||||||
|
}
|
||||||
|
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||||
|
return nil, decision, merrors.InvalidArgument("estimated fee is required")
|
||||||
|
}
|
||||||
|
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||||
|
return nil, decision, merrors.InvalidArgument("current native balance is required")
|
||||||
|
}
|
||||||
|
if network.GasTopUpPolicy == nil {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||||
|
if nativeCurrency == "" {
|
||||||
|
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||||
|
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||||
|
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatedTRX, err := tronToTRX(estimatedFee)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decision, err
|
||||||
|
}
|
||||||
|
currentTRX, err := tronToTRX(currentBalance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, decision, err
|
||||||
|
}
|
||||||
|
|
||||||
|
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||||
|
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||||
|
if !ok {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||||
|
}
|
||||||
|
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||||
|
}
|
||||||
|
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||||
|
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := estimatedTRX.Sub(currentTRX)
|
||||||
|
if required.IsNegative() {
|
||||||
|
required = decimal.Zero
|
||||||
|
}
|
||||||
|
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||||
|
|
||||||
|
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
|
||||||
|
if minBalanceTopUp.IsNegative() {
|
||||||
|
minBalanceTopUp = decimal.Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTopUp := bufferedRequired
|
||||||
|
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||||
|
rawTopUp = minBalanceTopUp
|
||||||
|
}
|
||||||
|
|
||||||
|
roundedTopUp := decimal.Zero
|
||||||
|
if rawTopUp.IsPositive() {
|
||||||
|
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
topUp := roundedTopUp
|
||||||
|
capHit := false
|
||||||
|
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||||
|
topUp = rule.MaxTopUp
|
||||||
|
capHit = true
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = GasTopUpDecision{
|
||||||
|
CurrentBalanceTRX: currentTRX,
|
||||||
|
EstimatedFeeTRX: estimatedTRX,
|
||||||
|
RequiredTRX: required,
|
||||||
|
BufferedRequiredTRX: bufferedRequired,
|
||||||
|
MinBalanceTopUpTRX: minBalanceTopUp,
|
||||||
|
RawTopUpTRX: rawTopUp,
|
||||||
|
RoundedTopUpTRX: roundedTopUp,
|
||||||
|
TopUpTRX: topUp,
|
||||||
|
CapHit: capHit,
|
||||||
|
OperationType: operationType(isContract),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !topUp.IsPositive() {
|
||||||
|
return nil, decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: strings.ToUpper(nativeCurrency),
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}, decision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
|
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||||
|
if err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return value.Div(tronBaseUnitFactor), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationType(contract bool) string {
|
||||||
|
if contract {
|
||||||
|
return "trc20"
|
||||||
|
}
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package tron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, topUp)
|
||||||
|
require.True(t, decision.TopUpTRX.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "46000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "TRX", topUp.GetCurrency())
|
||||||
|
require.Equal(t, "46", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "19000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "19", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "2000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "2", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(0),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "10000000", topUp.GetAmount())
|
||||||
|
require.True(t, decision.CapHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||||
|
network := tronNetwork(defaultPolicy())
|
||||||
|
wallet := &model.ManagedWallet{}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "15000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||||
|
policy := shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.1),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(10),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
Contract: &shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.5),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(5),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(100),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
network := tronNetwork(&policy)
|
||||||
|
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||||
|
|
||||||
|
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, topUp)
|
||||||
|
require.Equal(t, "15000000", topUp.GetAmount())
|
||||||
|
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||||
|
require.Equal(t, "trc20", decision.OperationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||||
|
return &shared.GasTopUpPolicy{
|
||||||
|
Default: shared.GasTopUpRule{
|
||||||
|
BufferPercent: decimal.NewFromFloat(0.15),
|
||||||
|
MinNativeBalance: decimal.NewFromFloat(20),
|
||||||
|
RoundingUnit: decimal.NewFromFloat(1),
|
||||||
|
MaxTopUp: decimal.NewFromFloat(500),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||||
|
return shared.Network{
|
||||||
|
Name: "tron_mainnet",
|
||||||
|
NativeToken: "TRX",
|
||||||
|
GasTopUpPolicy: policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronMoney(trx string) *moneyv1.Money {
|
||||||
|
value, _ := decimal.NewFromString(trx)
|
||||||
|
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: "TRX",
|
||||||
|
Amount: baseUnits.StringFixed(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/ethereum"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry maps configured network keys to chain drivers.
|
||||||
|
type Registry struct {
|
||||||
|
byNetwork map[string]driver.Driver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry selects drivers for the configured networks.
|
||||||
|
func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
|
||||||
|
if logger == nil {
|
||||||
|
return nil, merrors.InvalidArgument("driver registry: logger is required")
|
||||||
|
}
|
||||||
|
result := &Registry{byNetwork: map[string]driver.Driver{}}
|
||||||
|
for _, network := range networks {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(network.Name))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chainDriver, err := resolveDriver(logger, name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("unsupported chain driver", zap.String("network", name), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.byNetwork[name] = chainDriver
|
||||||
|
}
|
||||||
|
if len(result.byNetwork) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
|
||||||
|
}
|
||||||
|
logger.Info("chain drivers configured", zap.Int("count", len(result.byNetwork)))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver resolves a driver for the provided network key.
|
||||||
|
func (r *Registry) Driver(network string) (driver.Driver, error) {
|
||||||
|
if r == nil || len(r.byNetwork) == 0 {
|
||||||
|
return nil, merrors.Internal("driver registry is not configured")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(network))
|
||||||
|
if key == "" {
|
||||||
|
return nil, merrors.InvalidArgument("network is required")
|
||||||
|
}
|
||||||
|
chainDriver, ok := r.byNetwork[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
|
||||||
|
}
|
||||||
|
return chainDriver, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(network, "tron"):
|
||||||
|
return tron.New(logger), nil
|
||||||
|
case strings.HasPrefix(network, "arbitrum"):
|
||||||
|
return arbitrum.New(logger), nil
|
||||||
|
case strings.HasPrefix(network, "ethereum"):
|
||||||
|
return ethereum.New(logger), nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument("unsupported chain network " + network)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ type TransferExecutor interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
|
||||||
return &onChainExecutor{
|
return &onChainExecutor{
|
||||||
logger: logger.Named("executor"),
|
logger: logger.Named("executor"),
|
||||||
keyManager: keyManager,
|
keyManager: keyManager,
|
||||||
clients: map[string]*ethclient.Client{},
|
clients: clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,34 +42,33 @@ type onChainExecutor struct {
|
|||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
|
|
||||||
mu sync.Mutex
|
clients *rpcclient.Clients
|
||||||
clients map[string]*ethclient.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||||
if o.keyManager == nil {
|
if o.keyManager == nil {
|
||||||
o.logger.Error("key manager not configured")
|
o.logger.Warn("key manager not configured")
|
||||||
return "", executorInternal("key manager is not configured", nil)
|
return "", executorInternal("key manager is not configured", nil)
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
|
o.logger.Warn("network rpc url missing", zap.String("network", network.Name))
|
||||||
return "", executorInvalid("network rpc url is not configured")
|
return "", executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
if source == nil || transfer == nil {
|
if source == nil || transfer == nil {
|
||||||
o.logger.Error("transfer context missing")
|
o.logger.Warn("transfer context missing")
|
||||||
return "", executorInvalid("transfer context missing")
|
return "", executorInvalid("transfer context missing")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(source.KeyReference) == "" {
|
if strings.TrimSpace(source.KeyReference) == "" {
|
||||||
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||||
return "", executorInvalid("source wallet missing key reference")
|
return "", executorInvalid("source wallet missing key reference")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(source.DepositAddress) == "" {
|
if strings.TrimSpace(source.DepositAddress) == "" {
|
||||||
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||||
return "", executorInvalid("source wallet missing deposit address")
|
return "", executorInvalid("source wallet missing deposit address")
|
||||||
}
|
}
|
||||||
if !common.IsHexAddress(destinationAddress) {
|
if !common.IsHexAddress(destinationAddress) {
|
||||||
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||||
return "", executorInvalid("invalid destination address " + destinationAddress)
|
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +79,15 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
zap.String("destination", strings.ToLower(destinationAddress)),
|
zap.String("destination", strings.ToLower(destinationAddress)),
|
||||||
)
|
)
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
if err != nil {
|
||||||
o.logger.Warn("failed to initialise rpc client",
|
o.logger.Warn("failed to initialise rpc client",
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("rpc_url", rpcURL),
|
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
@@ -98,10 +101,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to fetch nonce",
|
o.logger.Warn("failed to fetch nonce", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to fetch nonce", err)
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
}
|
}
|
||||||
@@ -135,12 +137,11 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||||
|
|
||||||
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to read token decimals",
|
o.logger.Warn("failed to read token decimals", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
zap.String("contract", transfer.ContractAddress),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -152,10 +153,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to convert amount to base units",
|
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("amount", amount.Amount),
|
zap.String("amount", amount.Amount),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -188,18 +188,16 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to sign transaction",
|
o.logger.Warn("failed to sign transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
o.logger.Warn("failed to send transaction",
|
o.logger.Warn("failed to send transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to send transaction", err)
|
return "", executorInternal("failed to send transaction", err)
|
||||||
}
|
}
|
||||||
@@ -214,30 +212,6 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
return txHash, nil
|
return txHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
|
|
||||||
o.mu.Lock()
|
|
||||||
client, ok := o.clients[rpcURL]
|
|
||||||
o.mu.Unlock()
|
|
||||||
if ok {
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
o.mu.Lock()
|
|
||||||
defer o.mu.Unlock()
|
|
||||||
if existing, ok := o.clients[rpcURL]; ok {
|
|
||||||
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
|
|
||||||
c.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
o.clients[rpcURL] = c
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
if strings.TrimSpace(txHash) == "" {
|
if strings.TrimSpace(txHash) == "" {
|
||||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||||
@@ -249,7 +223,7 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
|||||||
return nil, executorInvalid("network rpc url is not configured")
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
|
|||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||||
callData, err := erc20ABI.Pack("decimals")
|
call := map[string]string{
|
||||||
if err != nil {
|
"to": strings.ToLower(token.Hex()),
|
||||||
return 0, executorInternal("failed to encode decimals call", err)
|
"data": "0x313ce567",
|
||||||
}
|
}
|
||||||
msg := ethereum.CallMsg{
|
var hexResp string
|
||||||
To: &token,
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, executorInternal("decimals call failed", err)
|
return 0, executorInternal("decimals call failed", err)
|
||||||
}
|
}
|
||||||
values, err := erc20ABI.Unpack("decimals", output)
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, executorInternal("failed to unpack decimals", err)
|
return 0, executorInternal("decimals decode failed", err)
|
||||||
}
|
}
|
||||||
if len(values) == 0 {
|
return val, nil
|
||||||
return 0, executorInternal("decimals call returned no data", nil)
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, executorInternal("decimals call returned unexpected type", nil)
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -18,10 +20,10 @@ func WithKeyManager(manager keymanager.Manager) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
// WithRPCClients configures pre-initialised RPC clients.
|
||||||
func WithTransferExecutor(executor TransferExecutor) Option {
|
func WithRPCClients(clients *rpcclient.Clients) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.executor = executor
|
s.rpcClients = clients
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDriverRegistry configures the chain driver registry.
|
||||||
|
func WithDriverRegistry(registry *drivers.Registry) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.drivers = registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithClock overrides the service clock.
|
// WithClock overrides the service clock.
|
||||||
func WithClock(clk clockpkg.Clock) Option {
|
func WithClock(clk clockpkg.Clock) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
196
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
196
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package rpcclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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),
|
||||||
|
zap.String("rpc_endpoint", l.endpoint),
|
||||||
|
}
|
||||||
|
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 len(bodyBytes) > 0 {
|
||||||
|
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
l.logger.Warn("RPC response error", 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"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
@@ -41,7 +43,9 @@ type Service struct {
|
|||||||
networks map[string]shared.Network
|
networks map[string]shared.Network
|
||||||
serviceWallet shared.ServiceWallet
|
serviceWallet shared.ServiceWallet
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
executor TransferExecutor
|
rpcClients *rpcclient.Clients
|
||||||
|
networkRegistry *rpcclient.Registry
|
||||||
|
drivers *drivers.Registry
|
||||||
commands commands.Registry
|
commands commands.Registry
|
||||||
|
|
||||||
chainv1.UnimplementedChainGatewayServiceServer
|
chainv1.UnimplementedChainGatewayServiceServer
|
||||||
@@ -73,6 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.networks = map[string]shared.Network{}
|
svc.networks = map[string]shared.Network{}
|
||||||
}
|
}
|
||||||
svc.settings = svc.settings.withDefaults()
|
svc.settings = svc.settings.withDefaults()
|
||||||
|
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
|
||||||
|
|
||||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
@@ -121,6 +126,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate
|
|||||||
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||||
if s.storage == nil {
|
if s.storage == nil {
|
||||||
return errStorageUnavailable
|
return errStorageUnavailable
|
||||||
@@ -131,11 +144,13 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
|||||||
func commandsWalletDeps(s *Service) wallet.Deps {
|
func commandsWalletDeps(s *Service) wallet.Deps {
|
||||||
return wallet.Deps{
|
return wallet.Deps{
|
||||||
Logger: s.logger.Named("command"),
|
Logger: s.logger.Named("command"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
KeyManager: s.keyManager,
|
KeyManager: s.keyManager,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +158,11 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
|||||||
func commandsTransferDeps(s *Service) transfer.Deps {
|
func commandsTransferDeps(s *Service) transfer.Deps {
|
||||||
return transfer.Deps{
|
return transfer.Deps{
|
||||||
Logger: s.logger.Named("transfer_cmd"),
|
Logger: s.logger.Named("transfer_cmd"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
LaunchExecution: s.launchTransferExecution,
|
LaunchExecution: s.launchTransferExecution,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
@@ -65,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
|||||||
require.Equal(t, 1, repo.wallets.count())
|
require.Equal(t, 1, repo.wallets.count())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-native",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-1",
|
||||||
|
Asset: &ichainv1.Asset{
|
||||||
|
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "ETH",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.GetWallet())
|
||||||
|
require.Equal(t, "ETH", resp.GetWallet().GetAsset().GetTokenSymbol())
|
||||||
|
require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress())
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
func TestSubmitTransfer_ManagedDestination(t *testing.T) {
|
||||||
svc, repo := newTestService(t)
|
svc, repo := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -143,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
|
|||||||
require.Equal(t, codes.NotFound, st.Code())
|
require.Equal(t, codes.NotFound, st.Code())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) {
|
||||||
|
svc, repo := newTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: "idem-balance",
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
OwnerRef: "owner-1",
|
||||||
|
Asset: &ichainv1.Asset{
|
||||||
|
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
|
||||||
|
TokenSymbol: "USDC",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
walletRef := createResp.GetWallet().GetWalletRef()
|
||||||
|
|
||||||
|
err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{
|
||||||
|
WalletRef: walletRef,
|
||||||
|
Available: &moneyv1.Money{Currency: "USDC", Amount: "25"},
|
||||||
|
NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.5"},
|
||||||
|
CalculatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.GetBalance())
|
||||||
|
require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount())
|
||||||
|
require.Equal(t, "ETH", resp.GetBalance().GetNativeAvailable().GetCurrency())
|
||||||
|
}
|
||||||
|
|
||||||
// ---- in-memory storage implementation ----
|
// ---- in-memory storage implementation ----
|
||||||
|
|
||||||
type inMemoryRepository struct {
|
type inMemoryRepository struct {
|
||||||
@@ -526,18 +577,23 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
|
|||||||
return int64(requested)
|
return int64(requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
||||||
repo := newInMemoryRepository()
|
repo := newInMemoryRepository()
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
svc := NewService(logger, repo, nil,
|
networks := []shared.Network{{
|
||||||
WithKeyManager(&fakeKeyManager{}),
|
|
||||||
WithNetworks([]shared.Network{{
|
|
||||||
Name: "ethereum_mainnet",
|
Name: "ethereum_mainnet",
|
||||||
|
NativeToken: "ETH",
|
||||||
TokenConfigs: []shared.TokenContract{
|
TokenConfigs: []shared.TokenContract{
|
||||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||||
},
|
},
|
||||||
}}),
|
}}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := NewService(logger, repo, nil,
|
||||||
|
WithKeyManager(&fakeKeyManager{}),
|
||||||
|
WithNetworks(networks),
|
||||||
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||||
|
WithDriverRegistry(driverRegistry),
|
||||||
)
|
)
|
||||||
return svc, repo
|
return svc, repo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package gateway
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||||
|
const defaultRPCRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
// CacheSettings holds tunable gateway behaviour.
|
// CacheSettings holds tunable gateway behaviour.
|
||||||
type CacheSettings struct {
|
type CacheSettings struct {
|
||||||
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||||
|
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultSettings() CacheSettings {
|
func defaultSettings() CacheSettings {
|
||||||
return CacheSettings{
|
return CacheSettings{
|
||||||
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||||
|
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ func (s CacheSettings) withDefaults() CacheSettings {
|
|||||||
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||||
}
|
}
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,3 +34,10 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) rpcTimeout() time.Duration {
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
return defaultRPCRequestTimeout
|
||||||
|
}
|
||||||
|
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "github.com/shopspring/decimal"
|
||||||
|
|
||||||
|
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
|
||||||
|
type GasTopUpRule struct {
|
||||||
|
BufferPercent decimal.Decimal
|
||||||
|
MinNativeBalance decimal.Decimal
|
||||||
|
RoundingUnit decimal.Decimal
|
||||||
|
MaxTopUp decimal.Decimal
|
||||||
|
}
|
||||||
|
|
||||||
|
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
|
||||||
|
type GasTopUpPolicy struct {
|
||||||
|
Default GasTopUpRule
|
||||||
|
Native *GasTopUpRule
|
||||||
|
Contract *GasTopUpRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule selects the policy rule for the transfer type.
|
||||||
|
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
|
||||||
|
if p == nil {
|
||||||
|
return GasTopUpRule{}, false
|
||||||
|
}
|
||||||
|
if contractTransfer && p.Contract != nil {
|
||||||
|
return *p.Contract, true
|
||||||
|
}
|
||||||
|
if !contractTransfer && p.Native != nil {
|
||||||
|
return *p.Native, true
|
||||||
|
}
|
||||||
|
return p.Default, true
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ type Network struct {
|
|||||||
ChainID uint64
|
ChainID uint64
|
||||||
NativeToken string
|
NativeToken string
|
||||||
TokenConfigs []TokenContract
|
TokenConfigs []TokenContract
|
||||||
|
GasTopUpPolicy *GasTopUpPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||||
|
|||||||
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errHexEmpty = errors.New("hex value is empty")
|
||||||
|
errHexInvalid = errors.New("invalid hex number")
|
||||||
|
errHexOutOfRange = errors.New("hex number out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodeHexBig parses a hex string that may include leading zero digits.
|
||||||
|
func DecodeHexBig(input string) (*big.Int, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, errHexEmpty
|
||||||
|
}
|
||||||
|
noPrefix := strings.TrimPrefix(trimmed, "0x")
|
||||||
|
if noPrefix == "" {
|
||||||
|
return nil, errHexEmpty
|
||||||
|
}
|
||||||
|
value := strings.TrimLeft(noPrefix, "0")
|
||||||
|
if value == "" {
|
||||||
|
return big.NewInt(0), nil
|
||||||
|
}
|
||||||
|
val := new(big.Int)
|
||||||
|
if _, ok := val.SetString(value, 16); !ok {
|
||||||
|
return nil, errHexInvalid
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros.
|
||||||
|
func DecodeHexUint8(input string) (uint8, error) {
|
||||||
|
val, err := DecodeHexBig(input)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if val == nil {
|
||||||
|
return 0, errHexInvalid
|
||||||
|
}
|
||||||
|
if val.BitLen() > 8 {
|
||||||
|
return 0, errHexOutOfRange
|
||||||
|
}
|
||||||
|
return uint8(val.Uint64()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDecodeHexUint8_LeadingZeros(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const resp = "0x0000000000000000000000000000000000000000000000000000000000000006"
|
||||||
|
val, err := DecodeHexUint8(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeHexUint8 error: %v", err)
|
||||||
|
}
|
||||||
|
if val != 6 {
|
||||||
|
t.Fatalf("DecodeHexUint8 value = %d, want 6", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
||||||
if s.executor == nil {
|
if s.drivers == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
||||||
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||||
}
|
}
|
||||||
}(transferRef, sourceWalletRef, network)
|
}(transferRef, sourceWalletRef, network)
|
||||||
}
|
}
|
||||||
@@ -44,13 +44,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
driverDeps := s.driverDeps()
|
||||||
|
chainDriver, err := s.driverForNetwork(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
@@ -62,7 +69,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
|
|
||||||
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
|
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||||
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
@@ -83,7 +90,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) {
|
func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,10 +99,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverDeps() driver.Deps {
|
||||||
|
return driver.Deps{
|
||||||
|
Logger: s.logger.Named("driver"),
|
||||||
|
Registry: s.networkRegistry,
|
||||||
|
KeyManager: s.keyManager,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
|
||||||
|
if s.drivers == nil {
|
||||||
|
return nil, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
return s.drivers.Driver(network)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
)
|
)
|
||||||
@@ -20,6 +21,7 @@ const (
|
|||||||
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
||||||
type ManagedWallet struct {
|
type ManagedWallet struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
pkgmodel.Describable `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
@@ -45,6 +47,7 @@ type WalletBalance struct {
|
|||||||
|
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
Available *moneyv1.Money `bson:"available" json:"available"`
|
Available *moneyv1.Money `bson:"available" json:"available"`
|
||||||
|
NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"`
|
||||||
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
|
||||||
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
|
||||||
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
|
||||||
@@ -77,10 +80,19 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
||||||
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
||||||
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
||||||
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
|
if m.Description != nil {
|
||||||
|
desc := strings.TrimSpace(*m.Description)
|
||||||
|
if desc == "" {
|
||||||
|
m.Description = nil
|
||||||
|
} else {
|
||||||
|
m.Description = &desc
|
||||||
|
}
|
||||||
|
}
|
||||||
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||||
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||||
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||||
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
|
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
|
||||||
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +100,31 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
func (b *WalletBalance) Normalize() {
|
func (b *WalletBalance) Normalize() {
|
||||||
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddress(address string) string {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if isHexAddress(trimmed) {
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexAddress(value string) bool {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x")
|
||||||
|
if len(trimmed) != 40 && len(trimmed) != 42 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
case r >= 'A' && r <= 'F':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
84
api/gateway/mntx/client/client.go
Normal file
84
api/gateway/mntx/client/client.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client wraps the Monetix gateway gRPC API.
|
||||||
|
type Client interface {
|
||||||
|
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||||
|
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||||
|
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatewayClient struct {
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
client mntxv1.MntxGatewayServiceClient
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New dials the Monetix gateway.
|
||||||
|
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
||||||
|
cfg.setDefaults()
|
||||||
|
if strings.TrimSpace(cfg.Address) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("mntx: address is required")
|
||||||
|
}
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||||
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
dialOpts = append(dialOpts, opts...)
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &gatewayClient{
|
||||||
|
conn: conn,
|
||||||
|
client: mntxv1.NewMntxGatewayServiceClient(conn),
|
||||||
|
cfg: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) Close() error {
|
||||||
|
if g.conn != nil {
|
||||||
|
return g.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
timeout := g.cfg.CallTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
|
ctx, cancel := g.callContext(ctx)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
defer cancel()
|
||||||
|
return g.client.GetCardPayoutStatus(ctx, req)
|
||||||
|
}
|
||||||
19
api/gateway/mntx/client/config.go
Normal file
19
api/gateway/mntx/client/config.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Config holds Monetix gateway client settings.
|
||||||
|
type Config struct {
|
||||||
|
Address string
|
||||||
|
DialTimeout time.Duration
|
||||||
|
CallTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) setDefaults() {
|
||||||
|
if c.DialTimeout <= 0 {
|
||||||
|
c.DialTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if c.CallTimeout <= 0 {
|
||||||
|
c.CallTimeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
37
api/gateway/mntx/client/fake.go
Normal file
37
api/gateway/mntx/client/fake.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fake implements Client for tests.
|
||||||
|
type Fake struct {
|
||||||
|
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||||
|
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||||
|
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
|
if f.CreateCardPayoutFn != nil {
|
||||||
|
return f.CreateCardPayoutFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &mntxv1.CardPayoutResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||||
|
if f.CreateCardTokenPayoutFn != nil {
|
||||||
|
return f.CreateCardTokenPayoutFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &mntxv1.CardTokenPayoutResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||||
|
if f.GetCardPayoutStatusFn != nil {
|
||||||
|
return f.GetCardPayoutStatusFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &mntxv1.GetCardPayoutStatusResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Close() error { return nil }
|
||||||
@@ -11,8 +11,8 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // 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/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
@@ -51,5 +51,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -62,12 +62,6 @@ const (
|
|||||||
OutboxStatusFailed OutboxStatus = "failed"
|
OutboxStatusFailed OutboxStatus = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Money represents an exact decimal amount with its currency.
|
|
||||||
type Money struct {
|
|
||||||
Currency string `bson:"currency" json:"currency"`
|
|
||||||
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
|
|
||||||
}
|
|
||||||
|
|
||||||
// LedgerMeta carries organization-scoped metadata for ledger entities.
|
// LedgerMeta carries organization-scoped metadata for ledger entities.
|
||||||
type LedgerMeta struct {
|
type LedgerMeta struct {
|
||||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
@@ -52,7 +52,7 @@ require (
|
|||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,8 +99,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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -227,12 +227,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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
// Client exposes typed helpers around the payment orchestrator gRPC API.
|
// Client exposes typed helpers around the payment orchestrator gRPC API.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
||||||
|
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||||
|
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||||
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||||
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
||||||
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
||||||
@@ -29,6 +31,8 @@ type Client interface {
|
|||||||
|
|
||||||
type grpcOrchestratorClient interface {
|
type grpcOrchestratorClient interface {
|
||||||
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
|
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
|
||||||
|
QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||||
|
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||||
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
|
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||||
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
|
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
|
||||||
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
|
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
|
||||||
@@ -97,6 +101,18 @@ func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestrator
|
|||||||
return c.client.QuotePayment(ctx, req)
|
return c.client.QuotePayment(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.QuotePayments(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.InitiatePayments(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
// Fake implements Client for tests.
|
// Fake implements Client for tests.
|
||||||
type Fake struct {
|
type Fake struct {
|
||||||
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
||||||
|
QuotePaymentsFn func(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||||
|
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||||
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||||
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
||||||
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
||||||
@@ -26,6 +28,20 @@ func (f *Fake) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymen
|
|||||||
return &orchestratorv1.QuotePaymentResponse{}, nil
|
return &orchestratorv1.QuotePaymentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||||
|
if f.QuotePaymentsFn != nil {
|
||||||
|
return f.QuotePaymentsFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &orchestratorv1.QuotePaymentsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||||
|
if f.InitiatePaymentsFn != nil {
|
||||||
|
return f.InitiatePaymentsFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &orchestratorv1.InitiatePaymentsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||||
if f.InitiatePaymentFn != nil {
|
if f.InitiatePaymentFn != nil {
|
||||||
return f.InitiatePaymentFn(ctx, req)
|
return f.InitiatePaymentFn(ctx, req)
|
||||||
|
|||||||
@@ -56,3 +56,11 @@ oracle:
|
|||||||
dial_timeout_seconds: 5
|
dial_timeout_seconds: 5
|
||||||
call_timeout_seconds: 3
|
call_timeout_seconds: 3
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
|
card_gateways:
|
||||||
|
monetix:
|
||||||
|
funding_address: "TXtjmjF99MhMdaMQrLopzcQ8cSBRLq5co8"
|
||||||
|
fee_wallet_ref: "694c124fd76f9f811ac57134"
|
||||||
|
|
||||||
|
fee_ledger_accounts:
|
||||||
|
monetix: "ledger:fees:monetix"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees
|
|||||||
|
|
||||||
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
|
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx
|
||||||
|
|
||||||
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
||||||
|
|
||||||
replace github.com/tech/sendico/ledger => ../../ledger
|
replace github.com/tech/sendico/ledger => ../../ledger
|
||||||
@@ -17,12 +19,13 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
||||||
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
|
||||||
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
|
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -59,5 +62,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
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 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L
|
|||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
@@ -215,12 +215,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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
@@ -45,6 +45,8 @@ type config struct {
|
|||||||
Ledger clientConfig `yaml:"ledger"`
|
Ledger clientConfig `yaml:"ledger"`
|
||||||
Gateway clientConfig `yaml:"gateway"`
|
Gateway clientConfig `yaml:"gateway"`
|
||||||
Oracle clientConfig `yaml:"oracle"`
|
Oracle clientConfig `yaml:"oracle"`
|
||||||
|
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||||
|
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientConfig struct {
|
type clientConfig struct {
|
||||||
@@ -54,6 +56,12 @@ type clientConfig struct {
|
|||||||
InsecureTransport bool `yaml:"insecure"`
|
InsecureTransport bool `yaml:"insecure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cardGatewayRouteConfig struct {
|
||||||
|
FundingAddress string `yaml:"funding_address"`
|
||||||
|
FeeAddress string `yaml:"fee_address"`
|
||||||
|
FeeWalletRef string `yaml:"fee_wallet_ref"`
|
||||||
|
}
|
||||||
|
|
||||||
func (c clientConfig) address() string {
|
func (c clientConfig) address() string {
|
||||||
return strings.TrimSpace(c.Address)
|
return strings.TrimSpace(c.Address)
|
||||||
}
|
}
|
||||||
@@ -150,6 +158,12 @@ func (i *Imp) Start() error {
|
|||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
|
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
|
||||||
}
|
}
|
||||||
|
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
|
||||||
|
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
|
||||||
|
}
|
||||||
|
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
|
||||||
|
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
|
||||||
|
}
|
||||||
return orchestrator.NewService(logger, repo, opts...), nil
|
return orchestrator.NewService(logger, repo, opts...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,3 +310,38 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
|
||||||
|
for key, route := range src {
|
||||||
|
trimmedKey := strings.TrimSpace(key)
|
||||||
|
if trimmedKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[trimmedKey] = orchestrator.CardGatewayRoute{
|
||||||
|
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
||||||
|
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
||||||
|
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(src))
|
||||||
|
for key, account := range src {
|
||||||
|
k := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
v := strings.TrimSpace(account)
|
||||||
|
if k == "" || v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultCardGateway = "monetix"
|
||||||
|
|
||||||
|
stepCodeGasTopUp = "gas_top_up"
|
||||||
|
stepCodeFundingTransfer = "funding_transfer"
|
||||||
|
stepCodeCardPayout = "card_payout"
|
||||||
|
stepCodeFeeTransfer = "fee_transfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
|
||||||
|
if len(s.deps.cardRoutes) == 0 {
|
||||||
|
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
|
||||||
|
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(gateway))
|
||||||
|
if key == "" {
|
||||||
|
key = defaultCardGateway
|
||||||
|
}
|
||||||
|
route, ok := s.deps.cardRoutes[key]
|
||||||
|
if !ok {
|
||||||
|
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
|
||||||
|
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(route.FundingAddress) == "" {
|
||||||
|
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
|
||||||
|
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
|
||||||
|
}
|
||||||
|
return route, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||||
|
if payment == nil {
|
||||||
|
return merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
intent := payment.Intent
|
||||||
|
source := intent.Source.ManagedWallet
|
||||||
|
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: source managed wallet is required")
|
||||||
|
}
|
||||||
|
if !s.deps.gateway.available() {
|
||||||
|
s.logger.Warn("card funding aborted: chain gateway unavailable")
|
||||||
|
return merrors.InvalidArgument("card funding: chain gateway unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
route, err := s.cardRoute(defaultCardGateway)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
|
||||||
|
fundingAddress := strings.TrimSpace(route.FundingAddress)
|
||||||
|
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
|
||||||
|
|
||||||
|
amount := cloneMoney(intent.Amount)
|
||||||
|
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
payoutAmount, err := cardPayoutAmount(payment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
feeMoney := (*moneyv1.Money)(nil)
|
||||||
|
if quote != nil {
|
||||||
|
feeMoney = quote.GetExpectedFeeTotal()
|
||||||
|
}
|
||||||
|
if feeMoney == nil && payment.LastQuote != nil {
|
||||||
|
feeMoney = payment.LastQuote.ExpectedFeeTotal
|
||||||
|
}
|
||||||
|
feeDecimal := decimal.Zero
|
||||||
|
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
|
||||||
|
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: fee currency is required")
|
||||||
|
}
|
||||||
|
feeDecimal, err = decimalFromMoney(feeMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feeRequired := feeDecimal.IsPositive()
|
||||||
|
|
||||||
|
fundingDest := &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
|
||||||
|
}
|
||||||
|
fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var feeTransferFee *moneyv1.Money
|
||||||
|
if feeRequired {
|
||||||
|
if feeWalletRef == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
|
||||||
|
}
|
||||||
|
feeDest := &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
|
||||||
|
}
|
||||||
|
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var estimatedTotalFee *moneyv1.Money
|
||||||
|
if gasCurrency != "" && !totalFee.IsNegative() {
|
||||||
|
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
|
||||||
|
}
|
||||||
|
|
||||||
|
var topUpMoney *moneyv1.Money
|
||||||
|
var topUpFee *moneyv1.Money
|
||||||
|
topUpPositive := false
|
||||||
|
if estimatedTotalFee != nil {
|
||||||
|
computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
|
||||||
|
WalletRef: sourceWalletRef,
|
||||||
|
EstimatedTotalFee: estimatedTotalFee,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if computeResp != nil {
|
||||||
|
topUpMoney = computeResp.GetTopupAmount()
|
||||||
|
}
|
||||||
|
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
|
||||||
|
amountDec, err := decimalFromMoney(topUpMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
topUpPositive = amountDec.IsPositive()
|
||||||
|
}
|
||||||
|
if topUpMoney != nil && topUpPositive {
|
||||||
|
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||||
|
}
|
||||||
|
if feeWalletRef == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
|
||||||
|
}
|
||||||
|
topUpDest := &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||||
|
}
|
||||||
|
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := ensureExecutionPlan(payment)
|
||||||
|
var gasStep *model.ExecutionStep
|
||||||
|
if topUpMoney != nil && topUpPositive {
|
||||||
|
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
|
||||||
|
gasStep.Description = "Top up native gas from fee wallet"
|
||||||
|
gasStep.Amount = cloneMoney(topUpMoney)
|
||||||
|
gasStep.NetworkFee = cloneMoney(topUpFee)
|
||||||
|
gasStep.SourceWalletRef = feeWalletRef
|
||||||
|
gasStep.DestinationRef = sourceWalletRef
|
||||||
|
}
|
||||||
|
|
||||||
|
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
|
||||||
|
fundStep.Description = "Transfer payout amount to card funding wallet"
|
||||||
|
fundStep.Amount = cloneMoney(amount)
|
||||||
|
fundStep.NetworkFee = cloneMoney(fundingFee)
|
||||||
|
fundStep.SourceWalletRef = sourceWalletRef
|
||||||
|
fundStep.DestinationRef = fundingAddress
|
||||||
|
|
||||||
|
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
|
||||||
|
cardStep.Description = "Submit card payout"
|
||||||
|
cardStep.Amount = cloneMoney(payoutAmount)
|
||||||
|
if card := intent.Destination.Card; card != nil {
|
||||||
|
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
|
||||||
|
cardStep.DestinationRef = masked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if feeRequired {
|
||||||
|
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
|
||||||
|
step.Description = "Transfer fee to fee wallet"
|
||||||
|
step.Amount = cloneMoney(feeMoney)
|
||||||
|
step.NetworkFee = cloneMoney(feeTransferFee)
|
||||||
|
step.SourceWalletRef = sourceWalletRef
|
||||||
|
step.DestinationRef = feeWalletRef
|
||||||
|
}
|
||||||
|
|
||||||
|
updateExecutionPlanTotalNetworkFee(plan)
|
||||||
|
|
||||||
|
exec := payment.Execution
|
||||||
|
if exec == nil {
|
||||||
|
exec = &model.ExecutionRefs{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if topUpMoney != nil && topUpPositive {
|
||||||
|
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
|
||||||
|
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
|
||||||
|
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||||
|
SourceWalletRef: feeWalletRef,
|
||||||
|
TargetWalletRef: sourceWalletRef,
|
||||||
|
EstimatedTotalFee: estimatedTotalFee,
|
||||||
|
Metadata: cloneMetadata(payment.Metadata),
|
||||||
|
ClientReference: payment.PaymentRef,
|
||||||
|
})
|
||||||
|
if gasErr != nil {
|
||||||
|
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return gasErr
|
||||||
|
}
|
||||||
|
if gasStep != nil {
|
||||||
|
actual := (*moneyv1.Money)(nil)
|
||||||
|
if ensureResp != nil {
|
||||||
|
actual = ensureResp.GetTopupAmount()
|
||||||
|
if transfer := ensureResp.GetTransfer(); transfer != nil {
|
||||||
|
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actualPositive := false
|
||||||
|
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
|
||||||
|
actualDec, err := decimalFromMoney(actual)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actualPositive = actualDec.IsPositive()
|
||||||
|
}
|
||||||
|
if actual != nil && actualPositive {
|
||||||
|
gasStep.Amount = cloneMoney(actual)
|
||||||
|
if strings.TrimSpace(actual.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||||
|
}
|
||||||
|
topUpDest := &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||||
|
}
|
||||||
|
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gasStep.NetworkFee = cloneMoney(topUpFee)
|
||||||
|
} else {
|
||||||
|
gasStep.Amount = nil
|
||||||
|
gasStep.NetworkFee = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gasStep != nil {
|
||||||
|
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
|
||||||
|
}
|
||||||
|
updateExecutionPlanTotalNetworkFee(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer payout amount to funding wallet.
|
||||||
|
fundReq := &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
|
||||||
|
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||||
|
SourceWalletRef: sourceWalletRef,
|
||||||
|
Destination: &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
|
||||||
|
},
|
||||||
|
Amount: amount,
|
||||||
|
Metadata: cloneMetadata(payment.Metadata),
|
||||||
|
ClientReference: payment.PaymentRef,
|
||||||
|
}
|
||||||
|
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fundResp != nil && fundResp.GetTransfer() != nil {
|
||||||
|
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
|
||||||
|
fundStep.TransferRef = exec.ChainTransferRef
|
||||||
|
}
|
||||||
|
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
|
||||||
|
|
||||||
|
payment.Execution = exec
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
|
||||||
|
if payment == nil {
|
||||||
|
return merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
intent := payment.Intent
|
||||||
|
card := intent.Destination.Card
|
||||||
|
if card == nil {
|
||||||
|
return merrors.InvalidArgument("card payout: card endpoint is required")
|
||||||
|
}
|
||||||
|
amount, err := cardPayoutAmount(payment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
amtDec, err := decimalFromMoney(amount)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
|
||||||
|
|
||||||
|
payoutID := payment.PaymentRef
|
||||||
|
currency := strings.TrimSpace(amount.GetCurrency())
|
||||||
|
holder := strings.TrimSpace(card.Cardholder)
|
||||||
|
meta := cloneMetadata(payment.Metadata)
|
||||||
|
|
||||||
|
var (
|
||||||
|
state *mntxv1.CardPayoutState
|
||||||
|
)
|
||||||
|
|
||||||
|
if token := strings.TrimSpace(card.Token); token != "" {
|
||||||
|
req := &mntxv1.CardTokenPayoutRequest{
|
||||||
|
PayoutId: payoutID,
|
||||||
|
AmountMinor: minor,
|
||||||
|
Currency: currency,
|
||||||
|
CardToken: token,
|
||||||
|
CardHolder: holder,
|
||||||
|
MaskedPan: strings.TrimSpace(card.MaskedPan),
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state = resp.GetPayout()
|
||||||
|
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
|
||||||
|
req := &mntxv1.CardPayoutRequest{
|
||||||
|
PayoutId: payoutID,
|
||||||
|
AmountMinor: minor,
|
||||||
|
Currency: currency,
|
||||||
|
CardPan: pan,
|
||||||
|
CardExpYear: card.ExpYear,
|
||||||
|
CardExpMonth: card.ExpMonth,
|
||||||
|
CardHolder: holder,
|
||||||
|
Metadata: meta,
|
||||||
|
}
|
||||||
|
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state = resp.GetPayout()
|
||||||
|
} else {
|
||||||
|
return merrors.InvalidArgument("card payout: either token or pan must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == nil {
|
||||||
|
return merrors.Internal("card payout: missing payout state")
|
||||||
|
}
|
||||||
|
recordCardPayoutState(payment, state)
|
||||||
|
exec := payment.Execution
|
||||||
|
if exec == nil {
|
||||||
|
exec = &model.ExecutionRefs{}
|
||||||
|
}
|
||||||
|
if exec.CardPayoutRef == "" {
|
||||||
|
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||||
|
}
|
||||||
|
payment.Execution = exec
|
||||||
|
|
||||||
|
plan := ensureExecutionPlan(payment)
|
||||||
|
if plan != nil {
|
||||||
|
step := ensureExecutionStep(plan, stepCodeCardPayout)
|
||||||
|
step.Description = "Submit card payout"
|
||||||
|
step.Amount = cloneMoney(amount)
|
||||||
|
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
|
||||||
|
step.DestinationRef = masked
|
||||||
|
}
|
||||||
|
if exec.CardPayoutRef != "" {
|
||||||
|
step.TransferRef = exec.CardPayoutRef
|
||||||
|
}
|
||||||
|
updateExecutionPlanTotalNetworkFee(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
feeMoney := (*moneyv1.Money)(nil)
|
||||||
|
if payment.LastQuote != nil {
|
||||||
|
feeMoney = payment.LastQuote.ExpectedFeeTotal
|
||||||
|
}
|
||||||
|
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
|
||||||
|
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
|
||||||
|
return merrors.InvalidArgument("card payout: fee currency is required")
|
||||||
|
}
|
||||||
|
feeDecimal, err := decimalFromMoney(feeMoney)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if feeDecimal.IsPositive() {
|
||||||
|
if !s.deps.gateway.available() {
|
||||||
|
s.logger.Warn("card fee aborted: chain gateway unavailable")
|
||||||
|
return merrors.InvalidArgument("card payout: chain gateway unavailable")
|
||||||
|
}
|
||||||
|
sourceWallet := intent.Source.ManagedWallet
|
||||||
|
if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" {
|
||||||
|
return merrors.InvalidArgument("card payout: source managed wallet is required")
|
||||||
|
}
|
||||||
|
route, err := s.cardRoute(defaultCardGateway)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
|
||||||
|
if feeWalletRef == "" {
|
||||||
|
return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists")
|
||||||
|
}
|
||||||
|
feeReq := &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
|
||||||
|
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||||
|
SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef),
|
||||||
|
Destination: &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
|
||||||
|
},
|
||||||
|
Amount: feeMoney,
|
||||||
|
Metadata: cloneMetadata(payment.Metadata),
|
||||||
|
ClientReference: payment.PaymentRef,
|
||||||
|
}
|
||||||
|
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
|
||||||
|
if feeErr != nil {
|
||||||
|
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
|
||||||
|
return feeErr
|
||||||
|
}
|
||||||
|
if feeResp != nil && feeResp.GetTransfer() != nil {
|
||||||
|
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
|
||||||
|
}
|
||||||
|
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
|
||||||
|
|
||||||
|
if plan != nil {
|
||||||
|
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
|
||||||
|
step.Description = "Transfer fee to fee wallet"
|
||||||
|
step.Amount = cloneMoney(feeMoney)
|
||||||
|
step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef)
|
||||||
|
step.DestinationRef = feeWalletRef
|
||||||
|
step.TransferRef = exec.FeeTransferRef
|
||||||
|
updateExecutionPlanTotalNetworkFee(plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
|
||||||
|
if payment == nil || state == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payment.CardPayout == nil {
|
||||||
|
payment.CardPayout = &model.CardPayout{}
|
||||||
|
}
|
||||||
|
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||||
|
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
|
||||||
|
payment.CardPayout.Status = state.GetStatus().String()
|
||||||
|
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
|
||||||
|
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
|
||||||
|
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
|
||||||
|
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
|
||||||
|
}
|
||||||
|
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
|
||||||
|
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
|
||||||
|
}
|
||||||
|
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
|
||||||
|
if payment == nil || payout == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordCardPayoutState(payment, payout)
|
||||||
|
|
||||||
|
if payment.Execution == nil {
|
||||||
|
payment.Execution = &model.ExecutionRefs{}
|
||||||
|
}
|
||||||
|
if payment.Execution.CardPayoutRef == "" {
|
||||||
|
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
|
||||||
|
}
|
||||||
|
|
||||||
|
payment.State = mapMntxStatusToState(payout.GetStatus())
|
||||||
|
switch payout.GetStatus() {
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
|
||||||
|
payment.FailureCode = model.PaymentFailureCodeUnspecified
|
||||||
|
payment.FailureReason = ""
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||||
|
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||||
|
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
|
||||||
|
default:
|
||||||
|
// leave as-is for pending/unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cardPayoutAmount(payment *model.Payment) (*moneyv1.Money, error) {
|
||||||
|
if payment == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
amount := cloneMoney(payment.Intent.Amount)
|
||||||
|
if payment.LastQuote != nil {
|
||||||
|
settlement := payment.LastQuote.ExpectedSettlementAmount
|
||||||
|
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||||
|
amount = cloneMoney(settlement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("card payout: amount is required")
|
||||||
|
}
|
||||||
|
return amount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||||
|
if !s.deps.gateway.available() {
|
||||||
|
return nil, merrors.InvalidArgument("chain gateway unavailable")
|
||||||
|
}
|
||||||
|
sourceWalletRef = strings.TrimSpace(sourceWalletRef)
|
||||||
|
if sourceWalletRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("source wallet ref is required")
|
||||||
|
}
|
||||||
|
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
|
||||||
|
SourceWalletRef: sourceWalletRef,
|
||||||
|
Destination: destination,
|
||||||
|
Amount: cloneMoney(amount),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
|
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
|
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||||
|
}
|
||||||
|
fee := resp.GetNetworkFee()
|
||||||
|
if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
|
||||||
|
s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef))
|
||||||
|
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||||
|
}
|
||||||
|
return cloneMoney(fee), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) {
|
||||||
|
total := decimal.Zero
|
||||||
|
currency := ""
|
||||||
|
for _, fee := range fees {
|
||||||
|
if fee == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(fee.GetAmount())
|
||||||
|
feeCurrency := strings.TrimSpace(fee.GetCurrency())
|
||||||
|
if amount == "" || feeCurrency == "" {
|
||||||
|
return decimal.Zero, "", merrors.InvalidArgument("network fee is required")
|
||||||
|
}
|
||||||
|
value, err := decimalFromMoney(fee)
|
||||||
|
if err != nil {
|
||||||
|
return decimal.Zero, "", err
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = feeCurrency
|
||||||
|
} else if !strings.EqualFold(currency, feeCurrency) {
|
||||||
|
return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch")
|
||||||
|
}
|
||||||
|
total = total.Add(value)
|
||||||
|
}
|
||||||
|
return total, currency, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan {
|
||||||
|
if payment == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if payment.ExecutionPlan == nil {
|
||||||
|
payment.ExecutionPlan = &model.ExecutionPlan{}
|
||||||
|
}
|
||||||
|
return payment.ExecutionPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep {
|
||||||
|
if plan == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if code == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, step := range plan.Steps {
|
||||||
|
if step == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(step.Code, code) {
|
||||||
|
if step.Code == "" {
|
||||||
|
step.Code = code
|
||||||
|
}
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step := &model.ExecutionStep{Code: code}
|
||||||
|
plan.Steps = append(plan.Steps, step)
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) {
|
||||||
|
if plan == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total := decimal.Zero
|
||||||
|
currency := ""
|
||||||
|
hasFee := false
|
||||||
|
for _, step := range plan.Steps {
|
||||||
|
if step == nil || step.NetworkFee == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fee := step.NetworkFee
|
||||||
|
if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.TrimSpace(fee.GetCurrency())
|
||||||
|
} else if !strings.EqualFold(currency, fee.GetCurrency()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := decimalFromMoney(fee)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total = total.Add(value)
|
||||||
|
hasFee = true
|
||||||
|
}
|
||||||
|
if !hasFee || currency == "" {
|
||||||
|
plan.TotalNetworkFee = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan.TotalNetworkFee = makeMoney(currency, total)
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
|
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
mo "github.com/tech/sendico/pkg/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceWalletRef = "wallet-src"
|
||||||
|
feeWalletRef = "wallet-fee"
|
||||||
|
fundingAddress = "0xfunding"
|
||||||
|
)
|
||||||
|
|
||||||
|
var estimateCalls []*chainv1.EstimateTransferFeeRequest
|
||||||
|
var computeCalls []*chainv1.ComputeGasTopUpRequest
|
||||||
|
var ensureCalls []*chainv1.EnsureGasTopUpRequest
|
||||||
|
var submitCalls []*chainv1.SubmitTransferRequest
|
||||||
|
|
||||||
|
gateway := &chainclient.Fake{
|
||||||
|
EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
|
||||||
|
estimateCalls = append(estimateCalls, req)
|
||||||
|
dest := req.GetDestination()
|
||||||
|
if req.GetSourceWalletRef() == feeWalletRef {
|
||||||
|
return &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.005"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if dest != nil && strings.TrimSpace(dest.GetExternalAddress()) != "" {
|
||||||
|
return &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
|
computeCalls = append(computeCalls, req)
|
||||||
|
return &chainv1.ComputeGasTopUpResponse{
|
||||||
|
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
|
ensureCalls = append(ensureCalls, req)
|
||||||
|
return &chainv1.EnsureGasTopUpResponse{
|
||||||
|
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
|
||||||
|
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||||
|
submitCalls = append(submitCalls, req)
|
||||||
|
return &chainv1.SubmitTransferResponse{
|
||||||
|
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
deps: serviceDependencies{
|
||||||
|
gateway: gatewayDependency{client: gateway},
|
||||||
|
cardRoutes: map[string]CardGatewayRoute{
|
||||||
|
defaultCardGateway: {
|
||||||
|
FundingAddress: fundingAddress,
|
||||||
|
FeeWalletRef: feeWalletRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payment := &model.Payment{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
IdempotencyKey: "pay-1",
|
||||||
|
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||||
|
Intent: model.PaymentIntent{
|
||||||
|
Kind: model.PaymentKindPayout,
|
||||||
|
Source: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeManagedWallet,
|
||||||
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
|
ManagedWalletRef: sourceWalletRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destination: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeCard,
|
||||||
|
Card: &model.CardEndpoint{
|
||||||
|
MaskedPan: "4111",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
quote := &orchestratorv1.PaymentQuote{
|
||||||
|
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
|
||||||
|
t.Fatalf("submitCardFundingTransfers error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(estimateCalls) != 4 {
|
||||||
|
t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls))
|
||||||
|
}
|
||||||
|
if len(computeCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls))
|
||||||
|
}
|
||||||
|
if len(ensureCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls))
|
||||||
|
}
|
||||||
|
if len(submitCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
computeCall := computeCalls[0]
|
||||||
|
if computeCall.GetWalletRef() != sourceWalletRef {
|
||||||
|
t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef())
|
||||||
|
}
|
||||||
|
if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
|
||||||
|
t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCall := ensureCalls[0]
|
||||||
|
if ensureCall.GetSourceWalletRef() != feeWalletRef {
|
||||||
|
t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef())
|
||||||
|
}
|
||||||
|
if ensureCall.GetTargetWalletRef() != sourceWalletRef {
|
||||||
|
t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef())
|
||||||
|
}
|
||||||
|
if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
|
||||||
|
t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund")
|
||||||
|
if fundCall.GetDestination().GetExternalAddress() != fundingAddress {
|
||||||
|
t.Fatalf("funding destination mismatch: %s", fundCall.GetDestination().GetExternalAddress())
|
||||||
|
}
|
||||||
|
if fundCall.GetAmount().GetCurrency() != "USDT" || fundCall.GetAmount().GetAmount() != "5" {
|
||||||
|
t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" {
|
||||||
|
t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := payment.ExecutionPlan
|
||||||
|
if plan == nil {
|
||||||
|
t.Fatal("expected execution plan to be populated")
|
||||||
|
}
|
||||||
|
gasStep := findExecutionStep(t, plan, stepCodeGasTopUp)
|
||||||
|
if gasStep.Amount.GetAmount() != "0.025" || gasStep.Amount.GetCurrency() != "ETH" {
|
||||||
|
t.Fatalf("gas step amount mismatch: %s %s", gasStep.Amount.GetCurrency(), gasStep.Amount.GetAmount())
|
||||||
|
}
|
||||||
|
if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" {
|
||||||
|
t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount())
|
||||||
|
}
|
||||||
|
if gasStep.TransferRef != "pay-1:card:gas" {
|
||||||
|
t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer)
|
||||||
|
if fundStep.NetworkFee.GetAmount() != "0.01" || fundStep.NetworkFee.GetCurrency() != "ETH" {
|
||||||
|
t.Fatalf("funding step fee mismatch: %s %s", fundStep.NetworkFee.GetCurrency(), fundStep.NetworkFee.GetAmount())
|
||||||
|
}
|
||||||
|
if fundStep.TransferRef != "pay-1:card:fund" {
|
||||||
|
t.Fatalf("funding step transfer ref mismatch: %s", fundStep.TransferRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardStep := findExecutionStep(t, plan, stepCodeCardPayout)
|
||||||
|
if cardStep.Amount.GetAmount() != "5" || cardStep.Amount.GetCurrency() != "USDT" {
|
||||||
|
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
|
||||||
|
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
|
||||||
|
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
|
||||||
|
}
|
||||||
|
if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" {
|
||||||
|
t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount())
|
||||||
|
}
|
||||||
|
if feeStep.TransferRef != "" {
|
||||||
|
t.Fatalf("expected fee step transfer ref to be empty before payout, got %s", feeStep.TransferRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" {
|
||||||
|
t.Fatalf("total network fee mismatch: %v", plan.TotalNetworkFee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceWalletRef = "wallet-src"
|
||||||
|
feeWalletRef = "wallet-fee"
|
||||||
|
)
|
||||||
|
|
||||||
|
var payoutReq *mntxv1.CardPayoutRequest
|
||||||
|
var submitCalls []*chainv1.SubmitTransferRequest
|
||||||
|
|
||||||
|
gateway := &chainclient.Fake{
|
||||||
|
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||||
|
submitCalls = append(submitCalls, req)
|
||||||
|
return &chainv1.SubmitTransferResponse{
|
||||||
|
Transfer: &chainv1.Transfer{TransferRef: "fee-transfer"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mntx := &mntxclient.Fake{
|
||||||
|
CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
|
payoutReq = req
|
||||||
|
return &mntxv1.CardPayoutResponse{
|
||||||
|
Payout: &mntxv1.CardPayoutState{
|
||||||
|
PayoutId: "payout-1",
|
||||||
|
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
deps: serviceDependencies{
|
||||||
|
gateway: gatewayDependency{client: gateway},
|
||||||
|
mntx: mntxDependency{client: mntx},
|
||||||
|
cardRoutes: map[string]CardGatewayRoute{
|
||||||
|
defaultCardGateway: {
|
||||||
|
FundingAddress: "0xfunding",
|
||||||
|
FeeWalletRef: feeWalletRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payment := &model.Payment{
|
||||||
|
PaymentRef: "pay-2",
|
||||||
|
IdempotencyKey: "pay-2",
|
||||||
|
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||||
|
Intent: model.PaymentIntent{
|
||||||
|
Kind: model.PaymentKindPayout,
|
||||||
|
Source: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeManagedWallet,
|
||||||
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
|
ManagedWalletRef: sourceWalletRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destination: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeCard,
|
||||||
|
Card: &model.CardEndpoint{
|
||||||
|
Pan: "5536913762657597",
|
||||||
|
Cardholder: "Stephan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||||
|
},
|
||||||
|
LastQuote: &model.PaymentQuoteSnapshot{
|
||||||
|
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"},
|
||||||
|
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.submitCardPayout(ctx, payment); err != nil {
|
||||||
|
t.Fatalf("submitCardPayout error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payoutReq == nil {
|
||||||
|
t.Fatal("expected card payout request to be sent")
|
||||||
|
}
|
||||||
|
if payoutReq.GetCurrency() != "RUB" || payoutReq.GetAmountMinor() != 39230 {
|
||||||
|
t.Fatalf("payout request amount mismatch: %s %d", payoutReq.GetCurrency(), payoutReq.GetAmountMinor())
|
||||||
|
}
|
||||||
|
|
||||||
|
if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" {
|
||||||
|
t.Fatalf("expected card payout ref recorded, got %v", payment.Execution)
|
||||||
|
}
|
||||||
|
if payment.Execution.FeeTransferRef != "fee-transfer" {
|
||||||
|
t.Fatalf("expected fee transfer ref recorded, got %v", payment.Execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(submitCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 fee transfer submission, got %d", len(submitCalls))
|
||||||
|
}
|
||||||
|
feeCall := submitCalls[0]
|
||||||
|
if feeCall.GetSourceWalletRef() != sourceWalletRef {
|
||||||
|
t.Fatalf("fee transfer source mismatch: %s", feeCall.GetSourceWalletRef())
|
||||||
|
}
|
||||||
|
if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef {
|
||||||
|
t.Fatalf("fee transfer destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef())
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := payment.ExecutionPlan
|
||||||
|
if plan == nil {
|
||||||
|
t.Fatal("expected execution plan to be populated")
|
||||||
|
}
|
||||||
|
cardStep := findExecutionStep(t, plan, stepCodeCardPayout)
|
||||||
|
if cardStep.TransferRef != "payout-1" {
|
||||||
|
t.Fatalf("card step transfer ref mismatch: %s", cardStep.TransferRef)
|
||||||
|
}
|
||||||
|
if cardStep.Amount.GetAmount() != "392.30" || cardStep.Amount.GetCurrency() != "RUB" {
|
||||||
|
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
|
||||||
|
}
|
||||||
|
|
||||||
|
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
|
||||||
|
if feeStep.TransferRef != "fee-transfer" {
|
||||||
|
t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef)
|
||||||
|
}
|
||||||
|
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
|
||||||
|
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
gateway := &chainclient.Fake{
|
||||||
|
EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
|
||||||
|
return &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
deps: serviceDependencies{
|
||||||
|
gateway: gatewayDependency{client: gateway},
|
||||||
|
cardRoutes: map[string]CardGatewayRoute{
|
||||||
|
defaultCardGateway: {
|
||||||
|
FundingAddress: "0xfunding",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payment := &model.Payment{
|
||||||
|
PaymentRef: "pay-3",
|
||||||
|
IdempotencyKey: "pay-3",
|
||||||
|
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||||
|
Intent: model.PaymentIntent{
|
||||||
|
Kind: model.PaymentKindPayout,
|
||||||
|
Source: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeManagedWallet,
|
||||||
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
|
ManagedWalletRef: "wallet-src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destination: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeCard,
|
||||||
|
Card: &model.CardEndpoint{
|
||||||
|
MaskedPan: "4111",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
quote := &orchestratorv1.PaymentQuote{
|
||||||
|
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.submitCardFundingTransfers(ctx, payment, quote)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing fee wallet ref")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fee wallet ref") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSubmitCall(t *testing.T, calls []*chainv1.SubmitTransferRequest, idempotencyKey string) *chainv1.SubmitTransferRequest {
|
||||||
|
t.Helper()
|
||||||
|
for _, call := range calls {
|
||||||
|
if call.GetIdempotencyKey() == idempotencyKey {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing submit transfer call for %s", idempotencyKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findExecutionStep(t *testing.T, plan *model.ExecutionPlan, code string) *model.ExecutionStep {
|
||||||
|
t.Helper()
|
||||||
|
if plan == nil {
|
||||||
|
t.Fatal("execution plan is nil")
|
||||||
|
}
|
||||||
|
for _, step := range plan.Steps {
|
||||||
|
if step != nil && strings.EqualFold(step.Code, code) {
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing execution step %s", code)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type paymentEngine interface {
|
||||||
|
EnsureRepository(ctx context.Context) error
|
||||||
|
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
|
||||||
|
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
|
||||||
|
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||||
|
Repository() storage.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultPaymentEngine struct {
|
||||||
|
svc *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
|
||||||
|
return e.svc.ensureRepository(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
|
||||||
|
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||||
|
return e.svc.resolvePaymentQuote(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||||
|
return e.svc.executePayment(ctx, store, payment, quote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e defaultPaymentEngine) Repository() storage.Repository {
|
||||||
|
return e.svc.storage
|
||||||
|
}
|
||||||
|
|
||||||
|
type paymentCommandFactory struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
|
||||||
|
return &paymentCommandFactory{
|
||||||
|
engine: engine,
|
||||||
|
logger: logger.Named("commands"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
|
||||||
|
return "ePaymentCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("quote_payment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
|
||||||
|
return "ePaymentsCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("quote_payments"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
|
||||||
|
return &initiatePaymentCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("initiate_payment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
|
||||||
|
return &initiatePaymentsCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("initiate_payments"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
|
||||||
|
return &cancelPaymentCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("cancel_payment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
|
||||||
|
return &initiateConversionCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("initiate_conversion"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
@@ -26,6 +24,7 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
|
|||||||
Amount: cloneMoney(src.GetAmount()),
|
Amount: cloneMoney(src.GetAmount()),
|
||||||
RequiresFX: src.GetRequiresFx(),
|
RequiresFX: src.GetRequiresFx(),
|
||||||
FeePolicy: src.GetFeePolicy(),
|
FeePolicy: src.GetFeePolicy(),
|
||||||
|
SettlementMode: src.GetSettlementMode(),
|
||||||
Attributes: cloneMetadata(src.GetAttributes()),
|
Attributes: cloneMetadata(src.GetAttributes()),
|
||||||
}
|
}
|
||||||
if src.GetFx() != nil {
|
if src.GetFx() != nil {
|
||||||
@@ -67,6 +66,19 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
if card := src.GetCard(); card != nil {
|
||||||
|
result.Type = model.EndpointTypeCard
|
||||||
|
result.Card = &model.CardEndpoint{
|
||||||
|
Pan: strings.TrimSpace(card.GetPan()),
|
||||||
|
Token: strings.TrimSpace(card.GetToken()),
|
||||||
|
Cardholder: strings.TrimSpace(card.GetCardholderName()),
|
||||||
|
ExpMonth: card.GetExpMonth(),
|
||||||
|
ExpYear: card.GetExpYear(),
|
||||||
|
Country: strings.TrimSpace(card.GetCountry()),
|
||||||
|
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +108,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
|
|||||||
FeeRules: cloneFeeRules(src.GetFeeRules()),
|
FeeRules: cloneFeeRules(src.GetFeeRules()),
|
||||||
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
||||||
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
|
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
|
||||||
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
|
|
||||||
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
|
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +125,21 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
|
|||||||
FailureReason: src.FailureReason,
|
FailureReason: src.FailureReason,
|
||||||
LastQuote: modelQuoteToProto(src.LastQuote),
|
LastQuote: modelQuoteToProto(src.LastQuote),
|
||||||
Execution: protoExecutionFromModel(src.Execution),
|
Execution: protoExecutionFromModel(src.Execution),
|
||||||
|
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
|
||||||
Metadata: cloneMetadata(src.Metadata),
|
Metadata: cloneMetadata(src.Metadata),
|
||||||
}
|
}
|
||||||
|
if src.CardPayout != nil {
|
||||||
|
payment.CardPayout = &orchestratorv1.CardPayout{
|
||||||
|
PayoutRef: src.CardPayout.PayoutRef,
|
||||||
|
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
|
||||||
|
Status: src.CardPayout.Status,
|
||||||
|
FailureReason: src.CardPayout.FailureReason,
|
||||||
|
CardCountry: src.CardPayout.CardCountry,
|
||||||
|
MaskedPan: src.CardPayout.MaskedPan,
|
||||||
|
ProviderCode: src.CardPayout.ProviderCode,
|
||||||
|
GatewayReference: src.CardPayout.GatewayReference,
|
||||||
|
}
|
||||||
|
}
|
||||||
if src.CreatedAt.IsZero() {
|
if src.CreatedAt.IsZero() {
|
||||||
payment.CreatedAt = timestamppb.New(time.Now().UTC())
|
payment.CreatedAt = timestamppb.New(time.Now().UTC())
|
||||||
} else {
|
} else {
|
||||||
@@ -135,6 +159,7 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent
|
|||||||
Amount: cloneMoney(src.Amount),
|
Amount: cloneMoney(src.Amount),
|
||||||
RequiresFx: src.RequiresFX,
|
RequiresFx: src.RequiresFX,
|
||||||
FeePolicy: src.FeePolicy,
|
FeePolicy: src.FeePolicy,
|
||||||
|
SettlementMode: src.SettlementMode,
|
||||||
Attributes: cloneMetadata(src.Attributes),
|
Attributes: cloneMetadata(src.Attributes),
|
||||||
}
|
}
|
||||||
if src.FX != nil {
|
if src.FX != nil {
|
||||||
@@ -176,6 +201,23 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case model.EndpointTypeCard:
|
||||||
|
if src.Card != nil {
|
||||||
|
card := &orchestratorv1.CardEndpoint{
|
||||||
|
CardholderName: src.Card.Cardholder,
|
||||||
|
ExpMonth: src.Card.ExpMonth,
|
||||||
|
ExpYear: src.Card.ExpYear,
|
||||||
|
Country: src.Card.Country,
|
||||||
|
MaskedPan: src.Card.MaskedPan,
|
||||||
|
}
|
||||||
|
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
|
||||||
|
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
|
||||||
|
}
|
||||||
|
if token := strings.TrimSpace(src.Card.Token); token != "" {
|
||||||
|
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
|
||||||
|
}
|
||||||
|
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// leave unspecified
|
// leave unspecified
|
||||||
}
|
}
|
||||||
@@ -205,6 +247,43 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
|
|||||||
CreditEntryRef: src.CreditEntryRef,
|
CreditEntryRef: src.CreditEntryRef,
|
||||||
FxEntryRef: src.FXEntryRef,
|
FxEntryRef: src.FXEntryRef,
|
||||||
ChainTransferRef: src.ChainTransferRef,
|
ChainTransferRef: src.ChainTransferRef,
|
||||||
|
CardPayoutRef: src.CardPayoutRef,
|
||||||
|
FeeTransferRef: src.FeeTransferRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &orchestratorv1.ExecutionStep{
|
||||||
|
Code: src.Code,
|
||||||
|
Description: src.Description,
|
||||||
|
Amount: cloneMoney(src.Amount),
|
||||||
|
NetworkFee: cloneMoney(src.NetworkFee),
|
||||||
|
SourceWalletRef: src.SourceWalletRef,
|
||||||
|
DestinationRef: src.DestinationRef,
|
||||||
|
TransferRef: src.TransferRef,
|
||||||
|
Metadata: cloneMetadata(src.Metadata),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps))
|
||||||
|
for _, step := range src.Steps {
|
||||||
|
if protoStep := protoExecutionStepFromModel(step); protoStep != nil {
|
||||||
|
steps = append(steps, protoStep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(steps) == 0 {
|
||||||
|
steps = nil
|
||||||
|
}
|
||||||
|
return &orchestratorv1.ExecutionPlan{
|
||||||
|
Steps: steps,
|
||||||
|
TotalNetworkFee: cloneMoney(src.TotalNetworkFee),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +299,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
|
|||||||
FeeRules: cloneFeeRules(src.FeeRules),
|
FeeRules: cloneFeeRules(src.FeeRules),
|
||||||
FxQuote: cloneFXQuote(src.FXQuote),
|
FxQuote: cloneFXQuote(src.FXQuote),
|
||||||
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
|
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
|
||||||
FeeQuoteToken: src.FeeQuoteToken,
|
|
||||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,60 +447,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
|
|
||||||
switch code {
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
|
|
||||||
return model.PaymentFailureCodeBalance
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
|
|
||||||
return model.PaymentFailureCodeLedger
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
|
|
||||||
return model.PaymentFailureCodeFX
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
|
|
||||||
return model.PaymentFailureCodeChain
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
|
|
||||||
return model.PaymentFailureCodeFees
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
|
|
||||||
return model.PaymentFailureCodePolicy
|
|
||||||
default:
|
|
||||||
return model.PaymentFailureCodeUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
|
|
||||||
if src == nil || dst == nil {
|
|
||||||
return merrors.InvalidArgument("payment payload is required")
|
|
||||||
}
|
|
||||||
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
|
|
||||||
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
|
|
||||||
dst.Intent = intentFromProto(src.GetIntent())
|
|
||||||
dst.State = modelStateFromProto(src.GetState())
|
|
||||||
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
|
|
||||||
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
|
|
||||||
dst.Metadata = cloneMetadata(src.GetMetadata())
|
|
||||||
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
|
|
||||||
dst.Execution = executionFromProto(src.GetExecution())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
|
|
||||||
if src == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &model.ExecutionRefs{
|
|
||||||
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
|
|
||||||
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
|
|
||||||
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
|
|
||||||
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
|
|
||||||
if req == nil {
|
|
||||||
return &paginationv1.CursorPageRequest{}
|
|
||||||
}
|
|
||||||
if req.GetPage() == nil {
|
|
||||||
return &paginationv1.CursorPageRequest{}
|
|
||||||
}
|
|
||||||
return req.GetPage()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEndpointFromProtoCard(t *testing.T) {
|
||||||
|
protoEndpoint := &orchestratorv1.PaymentEndpoint{
|
||||||
|
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
|
||||||
|
Card: &orchestratorv1.CardEndpoint{
|
||||||
|
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
|
||||||
|
CardholderName: " Jane Doe ",
|
||||||
|
ExpMonth: 12,
|
||||||
|
ExpYear: 2030,
|
||||||
|
Country: " US ",
|
||||||
|
MaskedPan: " ****1111 ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: map[string]string{"k": "v"},
|
||||||
|
}
|
||||||
|
|
||||||
|
modelEndpoint := endpointFromProto(protoEndpoint)
|
||||||
|
if modelEndpoint.Type != model.EndpointTypeCard {
|
||||||
|
t.Fatalf("expected card type, got %s", modelEndpoint.Type)
|
||||||
|
}
|
||||||
|
if modelEndpoint.Card == nil {
|
||||||
|
t.Fatalf("card payload missing")
|
||||||
|
}
|
||||||
|
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
|
||||||
|
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
|
||||||
|
}
|
||||||
|
if modelEndpoint.Metadata["k"] != "v" {
|
||||||
|
t.Fatalf("metadata not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtoEndpointFromModelCard(t *testing.T) {
|
||||||
|
modelEndpoint := model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeCard,
|
||||||
|
Card: &model.CardEndpoint{
|
||||||
|
Token: "tok_123",
|
||||||
|
Cardholder: "Jane",
|
||||||
|
ExpMonth: 1,
|
||||||
|
ExpYear: 2028,
|
||||||
|
Country: "GB",
|
||||||
|
MaskedPan: "****1234",
|
||||||
|
},
|
||||||
|
Metadata: map[string]string{"k": "v"},
|
||||||
|
}
|
||||||
|
|
||||||
|
protoEndpoint := protoEndpointFromModel(modelEndpoint)
|
||||||
|
card := protoEndpoint.GetCard()
|
||||||
|
if card == nil {
|
||||||
|
t.Fatalf("card payload missing in proto")
|
||||||
|
}
|
||||||
|
token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token)
|
||||||
|
if !ok || token.Token != "tok_123" {
|
||||||
|
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
|
||||||
|
}
|
||||||
|
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
|
||||||
|
t.Fatalf("card details mismatch: %#v", card)
|
||||||
|
}
|
||||||
|
if protoEndpoint.GetMetadata()["k"] != "v" {
|
||||||
|
t.Fatalf("metadata not preserved in proto endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/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"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type quotePaymentCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if err := requireNonNilIntent(req.GetIntent()); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
intent := req.GetIntent()
|
||||||
|
|
||||||
|
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.GetPreviewOnly() {
|
||||||
|
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteRef := primitive.NewObjectID().Hex()
|
||||||
|
quote.QuoteRef = quoteRef
|
||||||
|
record := &model.PaymentQuoteRecord{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
Intent: intentFromProto(intent),
|
||||||
|
Quote: quoteSnapshotToModel(quote),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
record.SetID(primitive.NewObjectID())
|
||||||
|
record.SetOrganizationRef(orgID)
|
||||||
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||||
|
}
|
||||||
|
|
||||||
|
type quotePaymentsCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
intents := req.GetIntents()
|
||||||
|
if len(intents) == 0 {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
|
||||||
|
expires := make([]time.Time, 0, len(intents))
|
||||||
|
for i, intent := range intents {
|
||||||
|
if err := requireNonNilIntent(intent); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteReq := &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: req.GetMeta(),
|
||||||
|
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
|
||||||
|
Intent: intent,
|
||||||
|
PreviewOnly: req.GetPreviewOnly(),
|
||||||
|
}
|
||||||
|
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quotes = append(quotes, quote)
|
||||||
|
expires = append(expires, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregate, err := aggregatePaymentQuotes(quotes)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
|
||||||
|
}
|
||||||
|
expiresAt, ok := minQuoteExpiry(expires)
|
||||||
|
if !ok {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteRef := ""
|
||||||
|
if !req.GetPreviewOnly() {
|
||||||
|
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteRef = primitive.NewObjectID().Hex()
|
||||||
|
record := &model.PaymentQuoteRecord{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
Intents: intentsFromProto(intents),
|
||||||
|
Quotes: quoteSnapshotsFromProto(quotes),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
record.SetID(primitive.NewObjectID())
|
||||||
|
record.SetOrganizationRef(orgID)
|
||||||
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
Aggregate: aggregate,
|
||||||
|
Quotes: quotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type initiatePaymentsCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||||
|
if quoteRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
intents := record.Intents
|
||||||
|
quotes := record.Quotes
|
||||||
|
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
|
||||||
|
intents = []model.PaymentIntent{record.Intent}
|
||||||
|
}
|
||||||
|
if len(quotes) == 0 && record.Quote != nil {
|
||||||
|
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
|
||||||
|
}
|
||||||
|
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payments := make([]*orchestratorv1.Payment, 0, len(intents))
|
||||||
|
for i := range intents {
|
||||||
|
intentProto := protoIntentFromModel(intents[i])
|
||||||
|
if err := requireNonNilIntent(intentProto); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteProto := modelQuoteToProto(quotes[i])
|
||||||
|
if quoteProto == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
|
||||||
|
}
|
||||||
|
quoteProto.QuoteRef = quoteRef
|
||||||
|
|
||||||
|
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||||
|
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
|
||||||
|
payments = append(payments, toProtoPayment(existing))
|
||||||
|
continue
|
||||||
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||||
|
if err = store.Create(ctx, entity); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payments = append(payments, toProtoPayment(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
|
||||||
|
}
|
||||||
|
|
||||||
|
type initiatePaymentCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
intent := req.GetIntent()
|
||||||
|
if err := requireNonNilIntent(intent); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||||
|
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||||
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
|
||||||
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||||
|
OrgRef: orgRef,
|
||||||
|
OrgID: orgID,
|
||||||
|
Meta: req.GetMeta(),
|
||||||
|
Intent: intent,
|
||||||
|
QuoteRef: req.GetQuoteRef(),
|
||||||
|
IdempotencyKey: req.GetIdempotencyKey(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if qerr, ok := err.(quoteResolutionError); ok {
|
||||||
|
switch qerr.code {
|
||||||
|
case "quote_not_found":
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
|
||||||
|
case "quote_expired":
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
|
||||||
|
case "quote_intent_mismatch":
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
|
||||||
|
default:
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if quoteSnapshot == nil {
|
||||||
|
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||||
|
|
||||||
|
if err = store.Create(ctx, entity); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
|
||||||
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||||
|
Payment: toProtoPayment(entity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelPaymentCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
payment, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||||
|
if err != nil {
|
||||||
|
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||||
|
}
|
||||||
|
if payment.State != model.PaymentStateAccepted {
|
||||||
|
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
|
||||||
|
}
|
||||||
|
payment.State = model.PaymentStateCancelled
|
||||||
|
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||||
|
payment.FailureReason = strings.TrimSpace(req.GetReason())
|
||||||
|
if err := store.Update(ctx, payment); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||||
|
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
|
||||||
|
}
|
||||||
|
|
||||||
|
type initiateConversionCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
|
||||||
|
}
|
||||||
|
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
|
||||||
|
}
|
||||||
|
fxIntent := req.GetFx()
|
||||||
|
if fxIntent == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||||
|
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||||
|
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||||
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
intentProto := &orchestratorv1.PaymentIntent{
|
||||||
|
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
|
||||||
|
Source: req.GetSource(),
|
||||||
|
Destination: req.GetDestination(),
|
||||||
|
Amount: amount,
|
||||||
|
RequiresFx: true,
|
||||||
|
Fx: fxIntent,
|
||||||
|
FeePolicy: req.GetFeePolicy(),
|
||||||
|
}
|
||||||
|
|
||||||
|
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: req.GetMeta(),
|
||||||
|
IdempotencyKey: req.GetIdempotencyKey(),
|
||||||
|
Intent: intentProto,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
|
||||||
|
|
||||||
|
if err = store.Create(ctx, entity); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||||
|
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||||
|
Conversion: toProtoPayment(entity),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/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"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type paymentEventHandler struct {
|
||||||
|
repo storage.Repository
|
||||||
|
ensureRepo func(ctx context.Context) error
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler {
|
||||||
|
return &paymentEventHandler{
|
||||||
|
repo: repo,
|
||||||
|
ensureRepo: ensure,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
|
||||||
|
if err := h.ensureRepo(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
|
||||||
|
}
|
||||||
|
transfer := req.GetEvent().GetTransfer()
|
||||||
|
transferRef := strings.TrimSpace(transfer.GetTransferRef())
|
||||||
|
if transferRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
|
||||||
|
}
|
||||||
|
store := h.repo.Payments()
|
||||||
|
if store == nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||||
|
}
|
||||||
|
payment, err := store.GetByChainTransferRef(ctx, transferRef)
|
||||||
|
if err != nil {
|
||||||
|
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||||
|
}
|
||||||
|
applyTransferStatus(req.GetEvent(), payment)
|
||||||
|
if err := store.Update(ctx, payment); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
|
||||||
|
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
|
||||||
|
if err := h.ensureRepo(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil || req.GetEvent() == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
|
||||||
|
}
|
||||||
|
event := req.GetEvent()
|
||||||
|
walletRef := strings.TrimSpace(event.GetWalletRef())
|
||||||
|
if walletRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
|
||||||
|
}
|
||||||
|
store := h.repo.Payments()
|
||||||
|
if store == nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||||
|
}
|
||||||
|
filter := &model.PaymentFilter{
|
||||||
|
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
|
||||||
|
DestinationRef: walletRef,
|
||||||
|
}
|
||||||
|
result, err := store.List(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
for _, payment := range result.Items {
|
||||||
|
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payment.State = model.PaymentStateSettled
|
||||||
|
payment.FailureCode = model.PaymentFailureCodeUnspecified
|
||||||
|
payment.FailureReason = ""
|
||||||
|
if payment.Execution == nil {
|
||||||
|
payment.Execution = &model.ExecutionRefs{}
|
||||||
|
}
|
||||||
|
if payment.Execution.ChainTransferRef == "" {
|
||||||
|
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
|
||||||
|
}
|
||||||
|
if err := store.Update(ctx, payment); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
|
||||||
|
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
|
||||||
|
}
|
||||||
|
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
|
||||||
|
if err := h.ensureRepo(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
|
||||||
|
}
|
||||||
|
payout := req.GetEvent().GetPayout()
|
||||||
|
paymentRef := strings.TrimSpace(payout.GetPayoutId())
|
||||||
|
if paymentRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
store := h.repo.Payments()
|
||||||
|
if store == nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||||
|
}
|
||||||
|
payment, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||||
|
if err != nil {
|
||||||
|
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCardPayoutUpdate(payment, payout)
|
||||||
|
if err := store.Update(ctx, payment); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
|
||||||
|
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
|
||||||
|
Payment: toProtoPayment(payment),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"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"
|
||||||
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type paymentQueryHandler struct {
|
||||||
|
repo storage.Repository
|
||||||
|
ensureRepo func(ctx context.Context) error
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
|
||||||
|
return &paymentQueryHandler{
|
||||||
|
repo: repo,
|
||||||
|
ensureRepo: ensure,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
|
||||||
|
if err := h.ensureRepo(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
store, err := ensurePaymentsStore(h.repo)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
entity, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||||
|
if err != nil {
|
||||||
|
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||||
|
}
|
||||||
|
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
|
||||||
|
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
|
||||||
|
if err := h.ensureRepo(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
store, err := ensurePaymentsStore(h.repo)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
filter := filterFromProto(req)
|
||||||
|
result, err := store.List(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
resp := &orchestratorv1.ListPaymentsResponse{
|
||||||
|
Page: &paginationv1.CursorPageResponse{
|
||||||
|
NextCursor: result.NextCursor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
|
||||||
|
for _, item := range result.Items {
|
||||||
|
resp.Payments = append(resp.Payments, toProtoPayment(item))
|
||||||
|
}
|
||||||
|
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) {
|
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
|
||||||
if pay == nil {
|
if pay == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustDebit := func(m *moneyv1.Money) {
|
applyChargeToDebit := func(m *moneyv1.Money) {
|
||||||
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
|
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
|
||||||
if err != nil || converted == nil {
|
if err != nil || converted == nil {
|
||||||
return
|
return
|
||||||
@@ -176,7 +176,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustSettlement := func(m *moneyv1.Money) {
|
applyChargeToSettlement := func(m *moneyv1.Money) {
|
||||||
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
|
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
|
||||||
if err != nil || converted == nil {
|
if err != nil || converted == nil {
|
||||||
return
|
return
|
||||||
@@ -186,12 +186,22 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustDebit(fee)
|
switch mode {
|
||||||
adjustSettlement(fee)
|
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
|
||||||
|
// Sender pays the fee: keep settlement fixed, increase debit.
|
||||||
|
applyChargeToDebit(fee)
|
||||||
|
default:
|
||||||
|
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
|
||||||
|
applyChargeToSettlement(fee)
|
||||||
|
}
|
||||||
|
|
||||||
if network != nil && network.GetNetworkFee() != nil {
|
if network != nil && network.GetNetworkFee() != nil {
|
||||||
adjustDebit(network.GetNetworkFee())
|
switch mode {
|
||||||
adjustSettlement(network.GetNetworkFee())
|
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
|
||||||
|
applyChargeToDebit(network.GetNetworkFee())
|
||||||
|
default:
|
||||||
|
applyChargeToSettlement(network.GetNetworkFee())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
|
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
|
||||||
@@ -204,20 +214,6 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
|||||||
return decimal.NewFromString(m.GetAmount())
|
return decimal.NewFromString(m.GetAmount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
|
|
||||||
if reference == nil || candidate == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value, err := decimal.NewFromString(candidate.GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
||||||
return &moneyv1.Money{
|
return &moneyv1.Money{
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
@@ -383,6 +379,22 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
|
|||||||
return breakdown
|
return breakdown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
|
||||||
|
if account == "" || len(lines) == 0 {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line.LedgerAccountRef = account
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func moneyEquals(a, b *moneyv1.Money) bool {
|
func moneyEquals(a, b *moneyv1.Money) bool {
|
||||||
if a == nil || b == nil {
|
if a == nil || b == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveTradeAmountsBuyBase(t *testing.T) {
|
func TestResolveTradeAmountsBuyBase(t *testing.T) {
|
||||||
@@ -47,11 +48,32 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote)
|
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED)
|
||||||
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
|
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
|
||||||
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
||||||
}
|
}
|
||||||
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" {
|
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" {
|
||||||
t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
|
||||||
|
pay := &moneyv1.Money{Currency: "USDT", Amount: "100"}
|
||||||
|
settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32
|
||||||
|
fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100
|
||||||
|
fxQuote := &oraclev1.Quote{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||||
|
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||||
|
Price: &moneyv1.Decimal{
|
||||||
|
Value: "79.32",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
|
||||||
|
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
|
||||||
|
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
||||||
|
}
|
||||||
|
if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" {
|
||||||
|
t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,10 +53,17 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
|
|||||||
if intent == nil {
|
if intent == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
dest := intent.GetDestination()
|
||||||
|
if dest == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if dest.GetCard() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
|
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil {
|
if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -69,3 +78,16 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
|
|||||||
}
|
}
|
||||||
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
|
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
|
||||||
|
switch status {
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
|
||||||
|
return model.PaymentStateSettled
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||||
|
return model.PaymentStateFailed
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
|
||||||
|
return model.PaymentStateSubmitted
|
||||||
|
default:
|
||||||
|
return model.PaymentStateUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) {
|
||||||
|
intent := &orchestratorv1.PaymentIntent{
|
||||||
|
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT,
|
||||||
|
Destination: &orchestratorv1.PaymentEndpoint{
|
||||||
|
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
|
||||||
|
Card: &orchestratorv1.CardEndpoint{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if shouldEstimateNetworkFee(intent) {
|
||||||
|
t.Fatalf("expected network fee estimation to be skipped for card payouts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) {
|
||||||
|
intent := &orchestratorv1.PaymentIntent{
|
||||||
|
Destination: &orchestratorv1.PaymentEndpoint{
|
||||||
|
Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{
|
||||||
|
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !shouldEstimateNetworkFee(intent) {
|
||||||
|
t.Fatalf("expected network fee estimation when destination is managed wallet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapMntxStatusToState(t *testing.T) {
|
||||||
|
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled {
|
||||||
|
t.Fatalf("processed should map to settled")
|
||||||
|
}
|
||||||
|
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed {
|
||||||
|
t.Fatalf("failed should map to failed")
|
||||||
|
}
|
||||||
|
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted {
|
||||||
|
t.Fatalf("pending should map to submitted")
|
||||||
|
}
|
||||||
|
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified {
|
||||||
|
t.Fatalf("unspecified should map to unspecified")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
|
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
@@ -46,10 +48,25 @@ func (o oracleDependency) available() bool {
|
|||||||
return o.client != nil
|
return o.client != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mntxDependency struct {
|
||||||
|
client mntxclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mntxDependency) available() bool {
|
||||||
|
return m.client != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardGatewayRoute maps a gateway to its funding and fee destinations.
|
||||||
|
type CardGatewayRoute struct {
|
||||||
|
FundingAddress string
|
||||||
|
FeeAddress string
|
||||||
|
FeeWalletRef string
|
||||||
|
}
|
||||||
|
|
||||||
// WithFeeEngine wires the fee engine client.
|
// WithFeeEngine wires the fee engine client.
|
||||||
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
|
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.fees = feesDependency{
|
s.deps.fees = feesDependency{
|
||||||
client: client,
|
client: client,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
}
|
}
|
||||||
@@ -59,21 +76,59 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
|
|||||||
// WithLedgerClient wires the ledger client.
|
// WithLedgerClient wires the ledger client.
|
||||||
func WithLedgerClient(client ledgerclient.Client) Option {
|
func WithLedgerClient(client ledgerclient.Client) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.ledger = ledgerDependency{client: client}
|
s.deps.ledger = ledgerDependency{client: client}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithChainGatewayClient wires the chain gateway client.
|
// WithChainGatewayClient wires the chain gateway client.
|
||||||
func WithChainGatewayClient(client chainclient.Client) Option {
|
func WithChainGatewayClient(client chainclient.Client) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.gateway = gatewayDependency{client: client}
|
s.deps.gateway = gatewayDependency{client: client}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOracleClient wires the FX oracle client.
|
// WithOracleClient wires the FX oracle client.
|
||||||
func WithOracleClient(client oracleclient.Client) Option {
|
func WithOracleClient(client oracleclient.Client) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.oracle = oracleDependency{client: client}
|
s.deps.oracle = oracleDependency{client: client}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMntxGateway wires the Monetix gateway client.
|
||||||
|
func WithMntxGateway(client mntxclient.Client) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.deps.mntx = mntxDependency{client: client}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
|
||||||
|
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if len(routes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
|
||||||
|
for k, v := range routes {
|
||||||
|
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
|
||||||
|
func WithFeeLedgerAccounts(routes map[string]string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if len(routes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
|
||||||
|
for k, v := range routes {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(k))
|
||||||
|
val := strings.TrimSpace(v)
|
||||||
|
if key == "" || val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.deps.feeLedgerAccounts[key] = val
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user