67 Commits

Author SHA1 Message Date
Arseni
32cccc7895 Added PostHog 2025-12-09 21:02:37 +03:00
81d2db394b Merge pull request 'removed auto-generated code' (#51) from interface-#50 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #51
2025-12-09 17:36:05 +00:00
Stephan D
8d6a302cb8 removed auto-generated code 2025-12-09 18:35:42 +01:00
0e48d2a318 Merge pull request 'double-sided quotation + fixed tests' (#49) from quote-#45 into main
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #49
2025-12-09 16:46:02 +00:00
Stephan D
32653e11fc double-sided quotation + fixed tests 2025-12-09 17:45:29 +01:00
a24ead2c36 Merge pull request 'quotation bff' (#46) from quote-#45 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #46
2025-12-09 15:30:46 +00:00
Stephan D
ce59cb1b26 quotation bff 2025-12-09 16:29:29 +01:00
cecaebfc5e Merge pull request 'Minor fixes for build to complete' (#44) from SEND001 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #44
2025-12-09 13:48:07 +00:00
e16f11d48a Merge branch 'main' into SEND001 2025-12-09 12:31:47 +00:00
Arseni
0804ad71f7 Minor fixes for build to complete 2025-12-09 15:30:46 +03:00
7a2f921de9 Merge pull request 'version bump + CBR fx ingestor' (#42) from cbr-#41 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #42
2025-12-08 18:54:03 +00:00
Stephan D
999f0684cb version bump + CBR fx ingestor 2025-12-08 19:52:03 +01:00
602b77ddc7 Merge pull request 'Navigation now flows entirely through go_router' (#35) from SEND001 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #35
2025-12-08 17:56:33 +00:00
Arseni
64ad8c8b38 Navigation now flows entirely through go_router 2025-12-08 17:40:25 +03:00
f478219990 Merge pull request 'Top Up Balance logic and Added fixes for routing' (#31) from SEND001 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #31
2025-12-06 23:35:53 +00:00
Arseni
bf39b1d401 Top Up Balance logic and Added fixes for routing 2025-12-05 20:29:43 +03:00
f7bf3138ac Merge pull request 'balance cache' (#30) from balance-cache-#29 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #30
2025-12-05 09:55:29 +00:00
Stephan D
7cb747f9a9 balance cache 2025-12-05 10:55:01 +01:00
f2658aea44 Merge pull request 'address book complete' (#28) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #28
2025-12-05 09:32:54 +00:00
Stephan D
5e49ee3244 address book complete 2025-12-05 10:27:55 +01:00
1073be187f Merge pull request 'fixed recipient storing problem' (#27) from address-book-#16 into main
All checks were successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #27
2025-12-05 08:38:31 +00:00
Stephan D
e854963fa6 fixed recipient storing problem 2025-12-05 09:37:51 +01:00
e5f283432b Merge pull request 'docker conflict resolved' (#26) from address-book-#16 into main
All checks were successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #26
2025-12-05 05:01:55 +00:00
Stephan D
d62a3413b2 docker conflict resolved 2025-12-05 06:01:23 +01:00
f720ba9bdf Merge pull request 'address-book-#16' (#25) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline failed
Reviewed-on: #25
2025-12-05 04:51:04 +00:00
Stephan D
98f254e34b docker conflict resolved 2025-12-05 05:50:34 +01:00
Stephan D
980bb96c74 relaxed healthcheck 2025-12-05 05:43:08 +01:00
4bb18f0210 Merge pull request 'fixed payment methods serialization deserialization' (#24) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
Reviewed-on: #24
2025-12-05 04:17:45 +00:00
Stephan D
574b40fe9f fixed payment methods serialization deserialization 2025-12-05 05:17:14 +01:00
a3a807e625 Merge pull request 'fixed port + recipient storing' (#23) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
Reviewed-on: #23
2025-12-05 03:49:13 +00:00
Stephan D
3b047af7ca fixed port + recipient storing 2025-12-05 04:48:50 +01:00
36cc46577c Merge pull request 'recipient saving' (#22) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #22
2025-12-05 03:36:47 +00:00
Stephan D
e1da16448b recipient saving 2025-12-05 04:34:11 +01:00
fed6f39de6 Merge pull request 'address-book-#16' (#21) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #21
2025-12-05 02:41:11 +00:00
Stephan D
85fb567ed9 rewored monetix gateawy to port 8084 2025-12-05 03:39:59 +01:00
Stephan D
fd07c10cba version bump 2025-12-05 03:31:33 +01:00
Stephan D
c44edc85fa fixed db installation 2025-12-05 03:25:52 +01:00
57a48fe2a3 Merge pull request 'migration to address book service' (#20) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #20
2025-12-05 02:03:11 +00:00
Stephan D
2b2a8afc2f migration to address book service 2025-12-05 03:02:43 +01:00
d431317a50 Merge pull request 'migration to address book service' (#19) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline failed
Reviewed-on: #19
2025-12-05 01:53:44 +00:00
Stephan D
b4c696f1ef migration to address book service 2025-12-05 02:52:02 +01:00
4d03a6ead8 Merge pull request 'address-book-#16' (#18) from address-book-#16 into main
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #18
2025-12-05 01:32:54 +00:00
Stephan D
2fe5151650 migration to address book service 2025-12-05 02:32:20 +01:00
Stephan D
2754a7aa13 migration to address book service 2025-12-05 02:30:49 +01:00
Stephan D
f71cc76f64 temp build 2025-12-05 01:32:41 +01:00
082d782a80 Merge pull request 'Missing policies' (#15) from policies-#14 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #15
2025-12-04 21:27:48 +00:00
Stephan D
18f8f3c476 Missing policies 2025-12-04 22:27:16 +01:00
659b90b6a5 Merge pull request '+ missing localizatoin +permissions fix' (#13) from recipients-#12 into main
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline failed
Reviewed-on: #13
2025-12-04 21:18:41 +00:00
Stephan D
84318883d2 + missing localizatoin +permissions fix 2025-12-04 22:18:09 +01:00
668ade2014 Merge pull request 'mntx-#10' (#11) from mntx-#10 into main
Some checks failed
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
Reviewed-on: #11
2025-12-04 20:51:30 +00:00
Stephan D
43c4866ad7 +logging 2025-12-04 21:50:49 +01:00
Stephan D
396a0c0c88 monetix gateway 2025-12-04 21:16:15 +01:00
f439f53524 Merge pull request 'icons update' (#9) from icon-#3 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #9
2025-12-04 15:40:56 +00:00
Stephan D
2052602050 icons update 2025-12-04 16:40:26 +01:00
Stephan D
bf85ca062c restucturization of recipients payment methods
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-12-04 14:42:25 +01:00
Arseni
3b04753f4e Revert "Merge branch 'devKA' into devka (resolve conflicts)"
Some checks are pending
ci/woodpecker/push/bump_version Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline is running
This reverts commit 5f4184760d, reversing
changes made to 5e1da9617f.

Reverting changes on main
2025-12-04 15:38:01 +03:00
Arseni
5f4184760d Merge branch 'devKA' into devka (resolve conflicts)
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version unknown status
2025-12-04 14:08:32 +03:00
Stephan D
5e1da9617f +address book service
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-12-01 21:20:10 +01:00
Stephan D
c4d34c5663 added wallet management localizations
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 23:25:31 +01:00
Stephan D
34420ca2fb +gas estimation
Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/bff Pipeline failed
2025-11-26 23:19:29 +01:00
Stephan D
d16703197d onchain balance getter implementation
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 22:25:15 +01:00
Stephan D
35897f9aa1 impreoved commands logging
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 21:45:38 +01:00
Stephan D
f59ee55084 removed untranslated.txt from repo
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 19:36:47 +01:00
Stephan D
8bf86c5c93 removed obsolete files
Some checks failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
2025-11-26 19:35:35 +01:00
Stephan D
5e8ff2adb7 removed CORS restrictions
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/payments_orchestrator Pipeline failed
2025-11-26 19:02:30 +01:00
Stephan D
da57b1d2e0 fixed providers initialization
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 18:17:30 +01:00
2ef9ac24a1 Merge pull request 'Added Localizations and ran small fixes' (#6) from devKA into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #6
2025-11-26 14:00:56 +00:00
369 changed files with 14304 additions and 3498 deletions

7
.gitignore vendored
View File

@@ -3,4 +3,9 @@
*.pb.gw.go
pubspec.lock
.DS_Store
update_dep.sh
analysis_options.yaml
devtools_options.yaml
untranslated.txt
generate_protos.sh
update_dep.sh
.vscode/

View File

@@ -1,33 +0,0 @@
depends_on:
- bff
- billing_fees
- chain_gateway
- db
- frontend
- fx_ingestor
- fx_oracle
- ledger
- nats
- notification
- payments_orchestrator
when:
event: push
branch: main
steps:
- name: bump-version
image: alpine:latest
environment:
GIT_AUTHOR_NAME: woodpecker
GIT_AUTHOR_EMAIL: ci@sendico.io
GIT_COMMITTER_NAME: woodpecker
GIT_COMMITTER_EMAIL: ci@sendico.io
commands:
- set -euo pipefail
- apk add --no-cache git
# make sure git knows who commits
- git config user.name "$GIT_AUTHOR_NAME"
- git config user.email "$GIT_AUTHOR_EMAIL"
# run your script (must do commit + push)
- sh ci/scripts/common/bump_version.sh

View File

@@ -1,11 +1,11 @@
matrix:
include:
- CHAIN_GATEWAY_IMAGE_PATH: chain/gateway
- CHAIN_GATEWAY_IMAGE_PATH: gateway/chain
CHAIN_GATEWAY_DOCKERFILE: ci/prod/compose/chain_gateway.dockerfile
CHAIN_GATEWAY_MONGO_SECRET_PATH: sendico/db
CHAIN_GATEWAY_RPC_SECRET_PATH: sendico/chain/gateway
CHAIN_GATEWAY_WALLET_SECRET_PATH: sendico/chain/gateway/wallet
CHAIN_GATEWAY_VAULT_SECRET_PATH: sendico/chain/gateway/vault
CHAIN_GATEWAY_RPC_SECRET_PATH: sendico/gateway/chain
CHAIN_GATEWAY_WALLET_SECRET_PATH: sendico/gateway/chain/wallet
CHAIN_GATEWAY_VAULT_SECRET_PATH: sendico/gateway/chain/vault
CHAIN_GATEWAY_ENV: prod
when:

View File

@@ -0,0 +1,74 @@
matrix:
include:
- MNTX_GATEWAY_IMAGE_PATH: gateway/mntx
MNTX_GATEWAY_DOCKERFILE: ci/prod/compose/mntx_gateway.dockerfile
MNTX_GATEWAY_ENV: prod
MNTX_GATEWAY_MONETIX_SECRET_PATH: sendico/gateway/monetix
MNTX_GATEWAY_NATS_SECRET_PATH: sendico/nats
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/mntx/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/mntx/deploy.sh

View File

@@ -18,14 +18,14 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -44,11 +44,11 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10
)

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -59,8 +59,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -212,8 +212,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -2,448 +2,16 @@ package fees
import (
"context"
"errors"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
// Implementation lives under internal/service/fees/internal/calculator.
type Calculator interface {
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
}
// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
Lines []*feesv1.DerivedPostingLine
Applied []*feesv1.AppliedRule
FxUsed *feesv1.FXUsed
}
// quoteCalculator is the default Calculator implementation.
type fxOracle interface {
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}
type quoteCalculator struct {
logger mlogger.Logger
oracle fxOracle
}
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
}
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
if plan == nil {
return nil, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, merrors.InvalidArgument("base amount cannot be negative")
}
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, plan.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 0, len(rules))
planID := ""
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
planID = planRef.Hex()
}
for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue
}
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil {
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
}
continue
}
if amount.Sign() == 0 {
continue
}
currency := intent.GetBaseAmount().GetCurrency()
if override := strings.TrimSpace(rule.Currency); override != "" {
currency = override
}
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
}
meta := map[string]string{
"fee_rule_id": rule.RuleID,
}
if planID != "" {
meta["fee_plan_id"] = planID
}
if rule.Metadata != nil {
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
meta["tax_code"] = taxCode
}
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
meta["tax_rate"] = taxRate
}
}
lines = append(lines, &feesv1.DerivedPostingLine{
LedgerAccountRef: ledgerAccountRef,
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: meta,
})
applied = append(applied, &feesv1.AppliedRule{
RuleId: rule.RuleID,
RuleVersion: planID,
Formula: rule.Formula,
Rounding: mapRoundingMode(rule.Rounding),
TaxCode: metadataValue(rule.Metadata, "tax_code"),
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
Parameters: cloneStringMap(rule.Metadata),
})
}
var fxUsed *feesv1.FXUsed
if trigger == model.TriggerFXConversion && c.oracle != nil {
fxUsed = c.buildFxUsed(ctx, intent)
}
return &CalculationResult{
Lines: lines,
Applied: applied,
FxUsed: fxUsed,
}, nil
}
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
scale, err := resolveRuleScale(rule, baseScale)
if err != nil {
return nil, 0, err
}
result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
percentageRat, perr := dmath.RatFromString(percentage)
if perr != nil {
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
}
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
fixedRat, ferr := dmath.RatFromString(fixed)
if ferr != nil {
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
}
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
minRat, merr := dmath.RatFromString(minStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
result = new(big.Rat).Set(minRat)
}
}
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
maxRat, merr := dmath.RatFromString(maxStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
}
if result.Sign() < 0 {
result = new(big.Rat).Abs(result)
}
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
if rerr != nil {
return nil, 0, rerr
}
return rounded, scale, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
attrFxProvider = "fx_provider"
attrFxSide = "fx_side"
attrFxRateOverride = "fx_rate"
)
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
if intent == nil || c.oracle == nil {
return nil
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
}
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
provider := strings.TrimSpace(attrs[attrFxProvider])
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
Meta: oracleclient.RequestMeta{},
Pair: pair,
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
return nil
}
if snapshot == nil {
return nil
}
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" {
rateValue = snapshot.Mid
}
if rateValue == "" {
rateValue = snapshot.Ask
}
if rateValue == "" {
rateValue = snapshot.Bid
}
return &feesv1.FXUsed{
Pair: pair,
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
Rate: &moneyv1.Decimal{Value: rateValue},
AsofUnixMs: snapshot.AsOf.UnixMilli(),
Provider: snapshot.Provider,
RateRef: snapshot.RateRef,
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
}
}
func parseFxSide(value string) fxv1.Side {
switch strings.ToLower(value) {
case "buy_base", "buy_base_sell_quote", "buy":
return fxv1.Side_BUY_BASE_SELL_QUOTE
case "sell_base", "sell_base_buy_quote", "sell":
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func inferScale(amount string) uint32 {
value := strings.TrimSpace(amount)
if value == "" {
return 0
}
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx]
}
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:]
}
if dot := strings.IndexByte(value, '.'); dot >= 0 {
return uint32(len(value[dot+1:]))
}
return 0
}
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(bookedAt) {
return false
}
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false
}
return ruleMatchesAttributes(rule, attributes)
}
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
if rule.Metadata != nil {
for _, field := range []string{"scale", "decimals", "precision"} {
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
return parseScale(field, value)
}
}
}
return fallback, nil
}
func parseScale(field, value string) (uint32, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty")
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value")
}
return uint32(parsed), nil
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
cloned := make(map[string]string, len(src))
for k, v := range src {
cloned[k] = v
}
return cloned
}
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
if attrValue, ok := attributes[key]; !ok || attrValue != value {
return false
}
}
return true
}
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}
func mapLineType(lineType string) accountingv1.PostingLineType {
switch strings.ToLower(lineType) {
case "tax":
return accountingv1.PostingLineType_POSTING_LINE_TAX
case "spread":
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case "reversal":
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_FEE
}
}
func mapEntrySide(entrySide string) accountingv1.EntrySide {
switch strings.ToLower(entrySide) {
case "debit":
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case "credit":
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func toDecimalRounding(mode string) dmath.RoundingMode {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "half_up":
return dmath.RoundingModeHalfUp
case "down":
return dmath.RoundingModeDown
case "half_even", "bankers":
return dmath.RoundingModeHalfEven
default:
return dmath.RoundingModeHalfEven
}
}
func mapRoundingMode(mode string) moneyv1.RoundingMode {
switch strings.ToLower(mode) {
case "half_up":
return moneyv1.RoundingMode_ROUND_HALF_UP
case "down":
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
}
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
}

View File

@@ -0,0 +1,442 @@
package calculator
import (
"context"
"errors"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
// fxOracle captures the oracle dependency for FX conversions.
type fxOracle interface {
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}
// New constructs the default calculator implementation.
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
if logger == nil {
logger = zap.NewNop()
}
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
}
}
type quoteCalculator struct {
logger mlogger.Logger
oracle fxOracle
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
if plan == nil {
return nil, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, merrors.InvalidArgument("base amount cannot be negative")
}
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, plan.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 0, len(rules))
planID := ""
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
planID = planRef.Hex()
}
for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue
}
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil {
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
}
continue
}
if amount.Sign() == 0 {
continue
}
currency := intent.GetBaseAmount().GetCurrency()
if override := strings.TrimSpace(rule.Currency); override != "" {
currency = override
}
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
}
meta := map[string]string{
"fee_rule_id": rule.RuleID,
}
if planID != "" {
meta["fee_plan_id"] = planID
}
if rule.Metadata != nil {
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
meta["tax_code"] = taxCode
}
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
meta["tax_rate"] = taxRate
}
}
lines = append(lines, &feesv1.DerivedPostingLine{
LedgerAccountRef: ledgerAccountRef,
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: meta,
})
applied = append(applied, &feesv1.AppliedRule{
RuleId: rule.RuleID,
RuleVersion: planID,
Formula: rule.Formula,
Rounding: mapRoundingMode(rule.Rounding),
TaxCode: metadataValue(rule.Metadata, "tax_code"),
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
Parameters: cloneStringMap(rule.Metadata),
})
}
var fxUsed *feesv1.FXUsed
if trigger == model.TriggerFXConversion && c.oracle != nil {
fxUsed = c.buildFxUsed(ctx, intent)
}
return &types.CalculationResult{
Lines: lines,
Applied: applied,
FxUsed: fxUsed,
}, nil
}
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
scale, err := resolveRuleScale(rule, baseScale)
if err != nil {
return nil, 0, err
}
result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
percentageRat, perr := dmath.RatFromString(percentage)
if perr != nil {
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
}
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
fixedRat, ferr := dmath.RatFromString(fixed)
if ferr != nil {
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
}
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
minRat, merr := dmath.RatFromString(minStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
result = new(big.Rat).Set(minRat)
}
}
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
maxRat, merr := dmath.RatFromString(maxStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
}
if result.Sign() < 0 {
result = new(big.Rat).Abs(result)
}
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
if rerr != nil {
return nil, 0, rerr
}
return rounded, scale, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
attrFxProvider = "fx_provider"
attrFxSide = "fx_side"
attrFxRateOverride = "fx_rate"
)
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
if intent == nil || c.oracle == nil {
return nil
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
}
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
provider := strings.TrimSpace(attrs[attrFxProvider])
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
Meta: oracleclient.RequestMeta{},
Pair: pair,
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
return nil
}
if snapshot == nil {
return nil
}
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" {
rateValue = snapshot.Mid
}
if rateValue == "" {
rateValue = snapshot.Ask
}
if rateValue == "" {
rateValue = snapshot.Bid
}
return &feesv1.FXUsed{
Pair: pair,
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
Rate: &moneyv1.Decimal{Value: rateValue},
AsofUnixMs: snapshot.AsOf.UnixMilli(),
Provider: snapshot.Provider,
RateRef: snapshot.RateRef,
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
}
}
func parseFxSide(value string) fxv1.Side {
switch strings.ToLower(value) {
case "buy_base", "buy_base_sell_quote", "buy":
return fxv1.Side_BUY_BASE_SELL_QUOTE
case "sell_base", "sell_base_buy_quote", "sell":
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func inferScale(amount string) uint32 {
value := strings.TrimSpace(amount)
if value == "" {
return 0
}
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx]
}
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:]
}
if dot := strings.IndexByte(value, '.'); dot >= 0 {
return uint32(len(value[dot+1:]))
}
return 0
}
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(bookedAt) {
return false
}
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false
}
return ruleMatchesAttributes(rule, attributes)
}
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
if rule.Metadata != nil {
for _, field := range []string{"scale", "decimals", "precision"} {
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
return parseScale(field, value)
}
}
}
return fallback, nil
}
func parseScale(field, value string) (uint32, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty")
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value")
}
return uint32(parsed), nil
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
cloned := make(map[string]string, len(src))
for k, v := range src {
cloned[k] = v
}
return cloned
}
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
if attrValue, ok := attributes[key]; !ok || attrValue != value {
return false
}
}
return true
}
func mapLineType(lineType string) accountingv1.PostingLineType {
switch strings.ToLower(lineType) {
case "tax":
return accountingv1.PostingLineType_POSTING_LINE_TAX
case "spread":
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case "reversal":
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_FEE
}
}
func mapEntrySide(entrySide string) accountingv1.EntrySide {
switch strings.ToLower(entrySide) {
case "debit":
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case "credit":
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func toDecimalRounding(mode string) dmath.RoundingMode {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "half_up":
return dmath.RoundingModeHalfUp
case "down":
return dmath.RoundingModeDown
case "half_even", "bankers":
return dmath.RoundingModeHalfEven
default:
return dmath.RoundingModeHalfEven
}
}
func mapRoundingMode(mode string) moneyv1.RoundingMode {
switch strings.ToLower(mode) {
case "half_up":
return moneyv1.RoundingMode_ROUND_HALF_UP
case "down":
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
}
}
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}

View File

@@ -0,0 +1,10 @@
package resolver
import "github.com/tech/sendico/pkg/merrors"
var (
// ErrNoFeeRuleFound indicates that no applicable rule exists for the given context.
ErrNoFeeRuleFound = merrors.ErrNoData
// ErrConflictingFeeRules indicates multiple rules share the same highest priority.
ErrConflictingFeeRules = merrors.ErrDataConflict
)

View File

@@ -0,0 +1,148 @@
package resolver
import (
"context"
"errors"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type planFinder interface {
FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
}
type feeResolver struct {
plans storage.PlansStore
finder planFinder
logger *zap.Logger
}
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
var finder planFinder
if pf, ok := plans.(planFinder); ok {
finder = pf
}
if logger == nil {
logger = zap.NewNop()
}
return &feeResolver{
plans: plans,
finder: finder,
logger: logger.Named("resolver"),
}
}
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) {
if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
}
// Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
return nil, nil, selErr
}
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
return nil, nil, err
}
}
plan, err := r.getGlobalPlan(ctx, at)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
}
r.logger.Warn("failed resolving global fee plan", zap.Error(err))
return nil, nil, err
}
rule, err := selectRule(plan, trigger, at, attrs)
if err != nil {
if !errors.Is(err, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule in global plan", zap.Error(err))
}
return nil, nil, err
}
return plan, rule, nil
}
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if r.finder != nil {
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
}
return r.plans.GetActivePlan(ctx, orgRef, at)
}
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
if r.finder != nil {
return r.finder.FindActiveGlobalPlan(ctx, at)
}
// Treat zero ObjectID as global in legacy path.
return r.plans.GetActivePlan(ctx, primitive.NilObjectID, at)
}
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
if plan == nil {
return nil, merrors.NoData("fees: no applicable fee rule found")
}
var selected *model.FeeRule
var highestPriority int
for _, rule := range plan.Rules {
if rule.Trigger != trigger {
continue
}
if rule.EffectiveFrom.After(at) {
continue
}
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
continue
}
if !matchesAppliesTo(rule.AppliesTo, attrs) {
continue
}
if selected == nil || rule.Priority > highestPriority {
copy := rule
selected = &copy
highestPriority = rule.Priority
continue
}
if rule.Priority == highestPriority {
return nil, merrors.DataConflict("fees: conflicting fee rules")
}
}
if selected == nil {
return nil, merrors.NoData("fees: no applicable fee rule found")
}
return selected, nil
}
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
if len(appliesTo) == 0 {
return true
}
for key, value := range appliesTo {
if attrs == nil {
return false
}
if attrs[key] != value {
return false
}
}
return true
}

View File

@@ -0,0 +1,315 @@
package resolver
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
t.Helper()
now := time.Now()
globalPlan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}}
resolver := New(store, zap.NewNop())
orgA := primitive.NewObjectID()
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if !plan.GetOrganizationRef().IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
}
if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
}
}
func TestResolver_OrgOverridesGlobal(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
globalPlan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
orgPlan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
orgPlan.SetOrganizationRef(org)
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected org plan rule, got error: %v", err)
}
if rule.RuleID != "org_capture" {
t.Fatalf("expected org rule, got %s", rule.RuleID)
}
otherOrg := primitive.NewObjectID()
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected global fallback for other org, got error: %v", err)
}
if rule.RuleID != "global_capture" {
t.Fatalf("expected global rule, got %s", rule.RuleID)
}
}
func TestResolver_SelectsHighestPriority(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
plan.SetOrganizationRef(org)
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected rule resolution, got error: %v", err)
}
if rule.RuleID != "high" {
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
}
plan.Rules = append(plan.Rules, model.FeeRule{
RuleID: "conflict",
Trigger: model.TriggerCapture,
Priority: 200,
Percentage: "0.02",
EffectiveFrom: now.Add(-time.Hour),
})
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) {
t.Fatalf("expected conflicting fee rules error, got %v", err)
}
}
func TestResolver_EffectiveDateFiltering(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
orgPlan := &model.FeePlan{
Active: true,
EffectiveFrom: past,
Rules: []model.FeeRule{
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
},
}
orgPlan.SetOrganizationRef(org)
globalPlan := &model.FeePlan{
Active: true,
EffectiveFrom: past,
Rules: []model.FeeRule{
{RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if rule.RuleID != "current" {
t.Fatalf("expected current global rule, got %s", rule.RuleID)
}
}
func TestResolver_AppliesToFiltering(t *testing.T) {
t.Helper()
now := time.Now()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)},
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"})
if err != nil {
t.Fatalf("expected card rule, got error: %v", err)
}
if rule.RuleID != "card" {
t.Fatalf("expected card rule, got %s", rule.RuleID)
}
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"})
if err != nil {
t.Fatalf("expected default rule, got error: %v", err)
}
if rule.RuleID != "default" {
t.Fatalf("expected default rule, got %s", rule.RuleID)
}
}
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
t.Helper()
now := time.Now()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) {
t.Fatalf("expected ErrNoFeeRuleFound, got %v", err)
}
}
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
p1 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
p1.SetOrganizationRef(org)
p2 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-30 * time.Minute),
Rules: []model.FeeRule{
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
p2.SetOrganizationRef(org)
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
resolver := New(store, zap.NewNop())
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
t.Fatalf("expected conflicting plans error, got %v", err)
}
}
type memoryPlansStore struct {
plans []*model.FeePlan
}
func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil }
func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil }
func (m *memoryPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
return nil, storage.ErrFeePlanNotFound
}
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if !orgRef.IsZero() {
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err
}
}
return m.FindActiveGlobalPlan(ctx, at)
}
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || plan.GetOrganizationRef() != orgRef {
continue
}
if !plan.Active {
continue
}
if plan.EffectiveFrom.After(at) {
continue
}
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
continue
}
matches = append(matches, plan)
}
if len(matches) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return matches[0], nil
}
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || !plan.GetOrganizationRef().IsZero() {
continue
}
if !plan.Active {
continue
}
if plan.EffectiveFrom.After(at) {
continue
}
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
continue
}
matches = append(matches, plan)
}
if len(matches) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return matches[0], nil
}
var _ storage.PlansStore = (*memoryPlansStore)(nil)

View File

@@ -1,6 +1,7 @@
package fees
import (
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
oracleclient "github.com/tech/sendico/fx/oracle/client"
clockpkg "github.com/tech/sendico/pkg/clock"
)
@@ -30,8 +31,18 @@ func WithCalculator(calculator Calculator) Option {
func WithOracleClient(oracle oracleclient.Client) Option {
return func(s *Service) {
s.oracle = oracle
if qc, ok := s.calculator.(*quoteCalculator); ok {
qc.oracle = oracle
// Rebuild default calculator if none was injected.
if s.calculator == nil {
s.calculator = internalcalculator.New(s.logger, oracle)
}
}
}
// WithFeeResolver injects a custom fee resolver (useful for tests).
func WithFeeResolver(r FeeResolver) Option {
return func(s *Service) {
if r != nil {
s.resolver = r
}
}
}

View File

@@ -0,0 +1,15 @@
package fees
import (
"context"
"time"
"github.com/tech/sendico/billing/fees/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// FeeResolver centralises plan/rule resolution with org override and global fallback.
// Implementations live under the internal/resolver package.
type FeeResolver interface {
ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error)
}

View File

@@ -8,7 +8,10 @@ import (
"strings"
"time"
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
clockpkg "github.com/tech/sendico/pkg/clock"
@@ -32,6 +35,7 @@ type Service struct {
clock clockpkg.Clock
calculator Calculator
oracle oracleclient.Client
resolver FeeResolver
feesv1.UnimplementedFeeEngineServer
}
@@ -52,7 +56,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.clock = clockpkg.NewSystem()
}
if svc.calculator == nil {
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
}
if svc.resolver == nil {
svc.resolver = resolver.New(repo.Plans(), svc.logger)
}
return svc
@@ -273,15 +280,34 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime()
}
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
}
s.logger.Warn("failed to load active fee plan", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
var orgPtr *primitive.ObjectID
if !orgRef.IsZero() {
orgPtr = &orgRef
}
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
case errors.Is(err, storage.ErrConflictingFeePlans):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans")
case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default:
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
}
}
originalRules := plan.Rules
plan.Rules = []model.FeeRule{*rule}
defer func() {
plan.Rules = originalRules
}()
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
if calcErr != nil {
if errors.Is(calcErr, merrors.ErrInvalidArg) {

View File

@@ -2,9 +2,11 @@ package fees
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
@@ -263,11 +265,21 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{
RuleID: "stub",
Trigger: model.TriggerCapture,
Priority: 1,
Percentage: "0.01",
LedgerAccountRef: "acct:stub",
EffectiveFrom: now.Add(-time.Hour),
},
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
result := &CalculationResult{
result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{
{
LedgerAccountRef: "acct:stub",
@@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
}
type stubPlansStore struct {
plan *model.FeePlan
plan *model.FeePlan
globalPlan *model.FeePlan
}
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
@@ -425,6 +438,17 @@ func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePla
}
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if !orgRef.IsZero() {
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err
}
}
return s.FindActiveGlobalPlan(context.Background(), at)
}
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
@@ -434,15 +458,31 @@ func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.Objec
if !s.plan.Active {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
if s.plan.EffectiveFrom.After(at) {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
return nil, storage.ErrFeePlanNotFound
}
return s.plan, nil
}
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
if s.globalPlan == nil {
return nil, storage.ErrFeePlanNotFound
}
if !s.globalPlan.Active {
return nil, storage.ErrFeePlanNotFound
}
if s.globalPlan.EffectiveFrom.After(at) {
return nil, storage.ErrFeePlanNotFound
}
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
return nil, storage.ErrFeePlanNotFound
}
return s.globalPlan, nil
}
type noopProducer struct{}
func (noopProducer) SendMessage(me.Envelope) error {
@@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time {
}
type stubCalculator struct {
result *CalculationResult
result *types.CalculationResult
err error
called bool
gotPlan *model.FeePlan
bookedAt time.Time
}
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
s.called = true
s.gotPlan = plan
s.bookedAt = bookedAt

View File

@@ -0,0 +1,23 @@
package fees
import (
"github.com/tech/sendico/billing/fees/storage/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}

View File

@@ -0,0 +1,12 @@
package types
import (
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
Lines []*feesv1.DerivedPostingLine
Applied []*feesv1.AppliedRule
FxUsed *feesv1.FXUsed
}

View File

@@ -42,21 +42,21 @@ func (*FeePlan) Collection() string {
// FeeRule represents a single pricing rule within a plan.
type FeeRule struct {
RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
}

View File

@@ -3,10 +3,14 @@ package store
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
@@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
return nil, err
}
// Recommended index to speed up active-plan lookups (org/global + active + dates).
activeIndex := &ri.Definition{
Keys: []ri.Key{
{Field: m.OrganizationRefField, Sort: ri.Asc},
{Field: "active", Sort: ri.Asc},
{Field: "effectiveFrom", Sort: ri.Asc},
{Field: "effectiveTo", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(activeIndex); err != nil {
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
}
return &plansStore{
logger: logger.Named("plans"),
repo: repo,
@@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
}
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
if err := validatePlan(plan); err != nil {
return err
}
if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err
}
if err := p.repo.Insert(ctx, plan, nil); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateFeePlan
@@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
}
if err := validatePlan(plan); err != nil {
return err
}
if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err
}
if err := p.repo.Update(ctx, plan); err != nil {
p.logger.Warn("failed to update fee plan", zap.Error(err))
return err
@@ -99,13 +127,42 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode
}
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
if orgRef.IsZero() {
return p.FindActiveGlobalPlan(ctx, at)
}
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
if err == nil {
return plan, nil
}
if errors.Is(err, storage.ErrFeePlanNotFound) {
return p.FindActiveGlobalPlan(ctx, at)
}
return nil, err
}
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if orgRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
}
query := repository.Query().Filter(repository.OrgField(), orgRef)
return p.findActivePlan(ctx, query, at)
}
limit := int64(1)
query := repository.Query().
Filter(repository.OrgField(), orgRef).
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
globalQuery := repository.Query().Or(
repository.Exists(repository.OrgField(), false),
repository.Query().Filter(repository.OrgField(), nil),
)
return p.findActivePlan(ctx, globalQuery, at)
}
var _ storage.PlansStore = (*plansStore)(nil)
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
limit := int64(2)
query := orgQuery.
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
Sort(repository.Field("effectiveFrom"), false).
@@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
),
)
var plan *model.FeePlan
var plans []*model.FeePlan
decoder := func(cursor *mongo.Cursor) error {
target := &model.FeePlan{}
if err := cursor.Decode(target); err != nil {
return err
}
plan = target
plans = append(plans, target)
return nil
}
@@ -135,10 +192,127 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
return nil, err
}
if plan == nil {
if len(plans) == 0 {
return nil, storage.ErrFeePlanNotFound
}
return plan, nil
if len(plans) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return plans[0], nil
}
var _ storage.PlansStore = (*plansStore)(nil)
func validatePlan(plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
}
if len(plan.Rules) == 0 {
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
}
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
}
// Ensure unique priority per (trigger, appliesTo) combination.
seen := make(map[string]struct{})
for _, rule := range plan.Rules {
if strings.TrimSpace(rule.Percentage) != "" {
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule percentage")
}
}
if strings.TrimSpace(rule.FixedAmount) != "" {
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
}
}
if strings.TrimSpace(rule.MinimumAmount) != "" {
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
}
}
if strings.TrimSpace(rule.MaximumAmount) != "" {
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
}
}
appliesKey := normalizeAppliesTo(rule.AppliesTo)
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
if _, ok := seen[priorityKey]; ok {
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
}
seen[priorityKey] = struct{}{}
}
return nil
}
func normalizeAppliesTo(applies map[string]string) string {
if len(applies) == 0 {
return ""
}
keys := make([]string, 0, len(applies))
for k := range applies {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+applies[k])
}
return strings.Join(parts, ",")
}
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || !plan.Active {
return nil
}
orgQuery := repository.Query()
if plan.OrganizationRef.IsZero() {
orgQuery = repository.Query().Or(
repository.Exists(repository.OrgField(), false),
repository.Query().Filter(repository.OrgField(), nil),
)
} else {
orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef)
}
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
newFrom := plan.EffectiveFrom
newTo := maxTime
if plan.EffectiveTo != nil {
newTo = *plan.EffectiveTo
}
query := orgQuery.
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo).
And(repository.Query().Or(
repository.Query().Filter(repository.Field("effectiveTo"), nil),
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom),
))
if id := plan.GetID(); id != nil && !id.IsZero() {
query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id))
}
limit := int64(1)
query = query.Limit(&limit)
var overlapFound bool
decoder := func(cursor *mongo.Cursor) error {
overlapFound = true
return nil
}
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil
}
return err
}
if overlapFound {
return storage.ErrConflictingFeePlans
}
return nil
}

View File

@@ -19,6 +19,8 @@ var (
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
// ErrConflictingFeePlans indicates multiple active plans matched a query.
ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans")
)
// Repository defines the root storage contract for the fees service.
@@ -32,5 +34,6 @@ type PlansStore interface {
Create(ctx context.Context, plan *model.FeePlan) error
Update(ctx context.Context, plan *model.FeePlan) error
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
// Legacy helper that now prefers an org plan and falls back to a global plan.
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
}

View File

@@ -1,83 +0,0 @@
package client
import (
"context"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateManagedWalletFn func(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error)
GetManagedWalletFn func(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error)
ListManagedWalletsFn func(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error)
GetWalletBalanceFn func(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error)
SubmitTransferFn func(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error)
GetTransferFn func(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error)
ListTransfersFn func(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error)
EstimateTransferFeeFn func(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error)
CloseFn func() error
}
func (f *Fake) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
if f.CreateManagedWalletFn != nil {
return f.CreateManagedWalletFn(ctx, req)
}
return &gatewayv1.CreateManagedWalletResponse{}, nil
}
func (f *Fake) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
if f.GetManagedWalletFn != nil {
return f.GetManagedWalletFn(ctx, req)
}
return &gatewayv1.GetManagedWalletResponse{}, nil
}
func (f *Fake) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
if f.ListManagedWalletsFn != nil {
return f.ListManagedWalletsFn(ctx, req)
}
return &gatewayv1.ListManagedWalletsResponse{}, nil
}
func (f *Fake) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
if f.GetWalletBalanceFn != nil {
return f.GetWalletBalanceFn(ctx, req)
}
return &gatewayv1.GetWalletBalanceResponse{}, nil
}
func (f *Fake) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
if f.SubmitTransferFn != nil {
return f.SubmitTransferFn(ctx, req)
}
return &gatewayv1.SubmitTransferResponse{}, nil
}
func (f *Fake) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
if f.GetTransferFn != nil {
return f.GetTransferFn(ctx, req)
}
return &gatewayv1.GetTransferResponse{}, nil
}
func (f *Fake) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
if f.ListTransfersFn != nil {
return f.ListTransfersFn(ctx, req)
}
return &gatewayv1.ListTransfersResponse{}, nil
}
func (f *Fake) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
if f.EstimateTransferFeeFn != nil {
return f.EstimateTransferFeeFn(ctx, req)
}
return &gatewayv1.EstimateTransferFeeResponse{}, nil
}
func (f *Fake) Close() error {
if f.CloseFn != nil {
return f.CloseFn()
}
return nil
}

View File

@@ -1,21 +0,0 @@
package gateway
import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
func cloneMoney(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()}
}
func cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
clone := make(map[string]string, len(input))
for k, v := range input {
clone[k] = v
}
return clone
}

View File

@@ -1,214 +0,0 @@
package gateway
import (
"context"
"strings"
"github.com/tech/sendico/chain/gateway/internal/keymanager"
"github.com/tech/sendico/chain/gateway/storage"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/grpc"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
var (
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
)
// Service implements the ChainGatewayService RPC contract.
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
clock clockpkg.Clock
networks map[string]Network
serviceWallet ServiceWallet
keyManager keymanager.Manager
executor TransferExecutor
gatewayv1.UnimplementedChainGatewayServiceServer
}
// NewService constructs the chain gateway service skeleton.
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("chain_gateway"),
storage: repo,
producer: producer,
clock: clockpkg.System{},
networks: map[string]Network{},
}
initMetrics()
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
if svc.clock == nil {
svc.clock = clockpkg.System{}
}
if svc.networks == nil {
svc.networks = map[string]Network{}
}
return svc
}
// Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
gatewayv1.RegisterChainGatewayServiceServer(reg, s)
})
}
func (s *Service) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
return executeUnary(ctx, s, "CreateManagedWallet", s.createManagedWalletHandler, req)
}
func (s *Service) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
return executeUnary(ctx, s, "GetManagedWallet", s.getManagedWalletHandler, req)
}
func (s *Service) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
return executeUnary(ctx, s, "ListManagedWallets", s.listManagedWalletsHandler, req)
}
func (s *Service) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
return executeUnary(ctx, s, "GetWalletBalance", s.getWalletBalanceHandler, req)
}
func (s *Service) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
return executeUnary(ctx, s, "SubmitTransfer", s.submitTransferHandler, req)
}
func (s *Service) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
return executeUnary(ctx, s, "GetTransfer", s.getTransferHandler, req)
}
func (s *Service) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
return executeUnary(ctx, s, "ListTransfers", s.listTransfersHandler, req)
}
func (s *Service) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
return executeUnary(ctx, s, "EstimateTransferFee", s.estimateTransferFeeHandler, req)
}
func (s *Service) ensureRepository(ctx context.Context) error {
if s.storage == nil {
return errStorageUnavailable
}
return s.storage.Ping(ctx)
}
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
start := svc.clock.Now()
resp, err := gsresponse.Unary(svc.logger, mservice.ChainGateway, handler)(ctx, req)
observeRPC(method, err, svc.clock.Now().Sub(start))
return resp, err
}
func resolveContractAddress(tokens []TokenContract, symbol string) string {
upper := strings.ToUpper(symbol)
for _, token := range tokens {
if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" {
return strings.ToLower(token.ContractAddress)
}
}
return ""
}
func generateWalletRef() string {
return primitive.NewObjectID().Hex()
}
func generateTransferRef() string {
return primitive.NewObjectID().Hex()
}
func chainKeyFromEnum(chain gatewayv1.ChainNetwork) (string, gatewayv1.ChainNetwork) {
if name, ok := gatewayv1.ChainNetwork_name[int32(chain)]; ok {
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
return key, chain
}
return "", gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func chainEnumFromName(name string) gatewayv1.ChainNetwork {
if name == "" {
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
key := "CHAIN_NETWORK_" + upper
if val, ok := gatewayv1.ChainNetwork_value[key]; ok {
return gatewayv1.ChainNetwork(val)
}
return gatewayv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func managedWalletStatusToProto(status model.ManagedWalletStatus) gatewayv1.ManagedWalletStatus {
switch status {
case model.ManagedWalletStatusActive:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
case model.ManagedWalletStatusSuspended:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
case model.ManagedWalletStatusClosed:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
default:
return gatewayv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
}
}
func transferStatusToModel(status gatewayv1.TransferStatus) model.TransferStatus {
switch status {
case gatewayv1.TransferStatus_TRANSFER_PENDING:
return model.TransferStatusPending
case gatewayv1.TransferStatus_TRANSFER_SIGNING:
return model.TransferStatusSigning
case gatewayv1.TransferStatus_TRANSFER_SUBMITTED:
return model.TransferStatusSubmitted
case gatewayv1.TransferStatus_TRANSFER_CONFIRMED:
return model.TransferStatusConfirmed
case gatewayv1.TransferStatus_TRANSFER_FAILED:
return model.TransferStatusFailed
case gatewayv1.TransferStatus_TRANSFER_CANCELLED:
return model.TransferStatusCancelled
default:
return ""
}
}
func transferStatusToProto(status model.TransferStatus) gatewayv1.TransferStatus {
switch status {
case model.TransferStatusPending:
return gatewayv1.TransferStatus_TRANSFER_PENDING
case model.TransferStatusSigning:
return gatewayv1.TransferStatus_TRANSFER_SIGNING
case model.TransferStatusSubmitted:
return gatewayv1.TransferStatus_TRANSFER_SUBMITTED
case model.TransferStatusConfirmed:
return gatewayv1.TransferStatus_TRANSFER_CONFIRMED
case model.TransferStatusFailed:
return gatewayv1.TransferStatus_TRANSFER_FAILED
case model.TransferStatusCancelled:
return gatewayv1.TransferStatus_TRANSFER_CANCELLED
default:
return gatewayv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}

View File

@@ -1,309 +0,0 @@
package gateway
import (
"context"
"errors"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) submitTransferHandler(ctx context.Context, req *gatewayv1.SubmitTransferRequest) gsresponse.Responder[gatewayv1.SubmitTransferResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
if req == nil {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
amount := req.GetAmount()
if amount == nil {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
}
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
if amountCurrency == "" {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
}
amountValue := strings.TrimSpace(amount.GetAmount())
if amountValue == "" {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
}
sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := s.networks[networkKey]
if !ok {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
destination, err := s.resolveDestination(ctx, req.GetDestination(), sourceWallet)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return gsresponse.NotFound[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
if err != nil {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
amountDec, err := decimal.NewFromString(amountValue)
if err != nil {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
}
netDec := amountDec.Sub(feeSum)
if netDec.IsNegative() {
return gsresponse.InvalidArgument[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
}
netAmount := cloneMoney(amount)
netAmount.Amount = netDec.String()
transfer := &model.Transfer{
IdempotencyKey: idempotencyKey,
TransferRef: generateTransferRef(),
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
Destination: destination,
Network: sourceWallet.Network,
TokenSymbol: sourceWallet.TokenSymbol,
ContractAddress: sourceWallet.ContractAddress,
RequestedAmount: cloneMoney(amount),
NetAmount: netAmount,
Fees: fees,
Status: model.TransferStatusPending,
ClientReference: strings.TrimSpace(req.GetClientReference()),
LastStatusAt: s.clock.Now().UTC(),
}
saved, err := s.storage.Transfers().Create(ctx, transfer)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)})
}
return gsresponse.Auto[gatewayv1.SubmitTransferResponse](s.logger, mservice.ChainGateway, err)
}
if s.executor != nil {
s.launchTransferExecution(saved.TransferRef, sourceWalletRef, networkCfg)
}
return gsresponse.Success(&gatewayv1.SubmitTransferResponse{Transfer: s.toProtoTransfer(saved)})
}
func (s *Service) getTransferHandler(ctx context.Context, req *gatewayv1.GetTransferRequest) gsresponse.Responder[gatewayv1.GetTransferResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
}
if req == nil {
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
transferRef := strings.TrimSpace(req.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
}
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return gsresponse.NotFound[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Auto[gatewayv1.GetTransferResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&gatewayv1.GetTransferResponse{Transfer: s.toProtoTransfer(transfer)})
}
func (s *Service) listTransfersHandler(ctx context.Context, req *gatewayv1.ListTransfersRequest) gsresponse.Responder[gatewayv1.ListTransfersResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err)
}
filter := model.TransferFilter{}
if req != nil {
filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef())
if status := transferStatusToModel(req.GetStatus()); status != "" {
filter.Status = status
}
if page := req.GetPage(); page != nil {
filter.Cursor = strings.TrimSpace(page.GetCursor())
filter.Limit = page.GetLimit()
}
}
result, err := s.storage.Transfers().List(ctx, filter)
if err != nil {
return gsresponse.Auto[gatewayv1.ListTransfersResponse](s.logger, mservice.ChainGateway, err)
}
protoTransfers := make([]*gatewayv1.Transfer, 0, len(result.Items))
for _, transfer := range result.Items {
protoTransfers = append(protoTransfers, s.toProtoTransfer(transfer))
}
resp := &gatewayv1.ListTransfersResponse{
Transfers: protoTransfers,
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
}
return gsresponse.Success(resp)
}
func (s *Service) estimateTransferFeeHandler(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) gsresponse.Responder[gatewayv1.EstimateTransferFeeResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, err)
}
if req == nil || req.GetAmount() == nil {
return gsresponse.InvalidArgument[gatewayv1.EstimateTransferFeeResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
}
currency := req.GetAmount().GetCurrency()
fee := &moneyv1.Money{
Currency: currency,
Amount: "0",
}
resp := &gatewayv1.EstimateTransferFeeResponse{
NetworkFee: fee,
EstimationContext: "not_implemented",
}
return gsresponse.Success(resp)
}
func (s *Service) toProtoTransfer(transfer *model.Transfer) *gatewayv1.Transfer {
if transfer == nil {
return nil
}
destination := &gatewayv1.TransferDestination{}
if transfer.Destination.ManagedWalletRef != "" {
destination.Destination = &gatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef}
} else if transfer.Destination.ExternalAddress != "" {
destination.Destination = &gatewayv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress}
}
destination.Memo = transfer.Destination.Memo
protoFees := make([]*gatewayv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
for _, fee := range transfer.Fees {
protoFees = append(protoFees, &gatewayv1.ServiceFeeBreakdown{
FeeCode: fee.FeeCode,
Amount: cloneMoney(fee.Amount),
Description: fee.Description,
})
}
asset := &gatewayv1.Asset{
Chain: chainEnumFromName(transfer.Network),
TokenSymbol: transfer.TokenSymbol,
ContractAddress: transfer.ContractAddress,
}
return &gatewayv1.Transfer{
TransferRef: transfer.TransferRef,
IdempotencyKey: transfer.IdempotencyKey,
OrganizationRef: transfer.OrganizationRef,
SourceWalletRef: transfer.SourceWalletRef,
Destination: destination,
Asset: asset,
RequestedAmount: cloneMoney(transfer.RequestedAmount),
NetAmount: cloneMoney(transfer.NetAmount),
Fees: protoFees,
Status: transferStatusToProto(transfer.Status),
TransactionHash: transfer.TxHash,
FailureReason: transfer.FailureReason,
CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()),
}
}
func (s *Service) resolveDestination(ctx context.Context, dest *gatewayv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) {
if dest == nil {
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
}
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
external := strings.TrimSpace(dest.GetExternalAddress())
if managedRef != "" && external != "" {
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
}
if managedRef != "" {
wallet, err := s.storage.Wallets().Get(ctx, managedRef)
if err != nil {
return model.TransferDestination{}, err
}
if !strings.EqualFold(wallet.Network, source.Network) {
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
}
if strings.TrimSpace(wallet.DepositAddress) == "" {
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
}
return model.TransferDestination{
ManagedWalletRef: wallet.WalletRef,
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil
}
if external == "" {
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
}
return model.TransferDestination{
ExternalAddress: strings.ToLower(external),
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil
}
func convertFees(fees []*gatewayv1.ServiceFeeBreakdown, currency string) ([]model.ServiceFee, decimal.Decimal, error) {
result := make([]model.ServiceFee, 0, len(fees))
sum := decimal.NewFromInt(0)
for _, fee := range fees {
if fee == nil || fee.GetAmount() == nil {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
}
amtCurrency := strings.ToUpper(strings.TrimSpace(fee.GetAmount().GetCurrency()))
if amtCurrency != strings.ToUpper(currency) {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee currency mismatch")
}
amtValue := strings.TrimSpace(fee.GetAmount().GetAmount())
if amtValue == "" {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
}
dec, err := decimal.NewFromString(amtValue)
if err != nil {
return nil, decimal.Decimal{}, merrors.InvalidArgument("invalid fee amount")
}
if dec.IsNegative() {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount must be non-negative")
}
sum = sum.Add(dec)
result = append(result, model.ServiceFee{
FeeCode: strings.TrimSpace(fee.GetFeeCode()),
Amount: cloneMoney(fee.GetAmount()),
Description: strings.TrimSpace(fee.GetDescription()),
})
}
return result, sum, nil
}

View File

@@ -1,213 +0,0 @@
package gateway
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) createManagedWalletHandler(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) gsresponse.Responder[gatewayv1.CreateManagedWalletResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
}
if req == nil {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
ownerRef := strings.TrimSpace(req.GetOwnerRef())
if ownerRef == "" {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
}
asset := req.GetAsset()
if asset == nil {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
}
chainKey, _ := chainKeyFromEnum(asset.GetChain())
if chainKey == "" {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
networkCfg, ok := s.networks[chainKey]
if !ok {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if tokenSymbol == "" {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
}
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
if contractAddress == "" {
contractAddress = resolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
if contractAddress == "" {
return gsresponse.InvalidArgument[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
}
}
walletRef := generateWalletRef()
if s.keyManager == nil {
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
}
keyInfo, err := s.keyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
if err != nil {
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
}
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
return gsresponse.Internal[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
}
wallet := &model.ManagedWallet{
IdempotencyKey: idempotencyKey,
WalletRef: walletRef,
OrganizationRef: organizationRef,
OwnerRef: ownerRef,
Network: chainKey,
TokenSymbol: tokenSymbol,
ContractAddress: contractAddress,
DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive,
Metadata: cloneMetadata(req.GetMetadata()),
}
created, err := s.storage.Wallets().Create(ctx, wallet)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
}
return gsresponse.Auto[gatewayv1.CreateManagedWalletResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&gatewayv1.CreateManagedWalletResponse{Wallet: s.toProtoManagedWallet(created)})
}
func (s *Service) getManagedWalletHandler(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) gsresponse.Responder[gatewayv1.GetManagedWalletResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
}
if req == nil {
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
}
wallet, err := s.storage.Wallets().Get(ctx, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return gsresponse.NotFound[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Auto[gatewayv1.GetManagedWalletResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&gatewayv1.GetManagedWalletResponse{Wallet: s.toProtoManagedWallet(wallet)})
}
func (s *Service) listManagedWalletsHandler(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) gsresponse.Responder[gatewayv1.ListManagedWalletsResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
}
filter := model.ManagedWalletFilter{}
if req != nil {
filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
filter.OwnerRef = strings.TrimSpace(req.GetOwnerRef())
if asset := req.GetAsset(); asset != nil {
filter.Network, _ = chainKeyFromEnum(asset.GetChain())
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
}
if page := req.GetPage(); page != nil {
filter.Cursor = strings.TrimSpace(page.GetCursor())
filter.Limit = page.GetLimit()
}
}
result, err := s.storage.Wallets().List(ctx, filter)
if err != nil {
return gsresponse.Auto[gatewayv1.ListManagedWalletsResponse](s.logger, mservice.ChainGateway, err)
}
protoWallets := make([]*gatewayv1.ManagedWallet, 0, len(result.Items))
for _, wallet := range result.Items {
protoWallets = append(protoWallets, s.toProtoManagedWallet(wallet))
}
resp := &gatewayv1.ListManagedWalletsResponse{
Wallets: protoWallets,
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
}
return gsresponse.Success(resp)
}
func (s *Service) getWalletBalanceHandler(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) gsresponse.Responder[gatewayv1.GetWalletBalanceResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
}
if req == nil {
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
}
balance, err := s.storage.Wallets().GetBalance(ctx, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
return gsresponse.NotFound[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](s.logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(balance)})
}
func (s *Service) toProtoManagedWallet(wallet *model.ManagedWallet) *gatewayv1.ManagedWallet {
if wallet == nil {
return nil
}
asset := &gatewayv1.Asset{
Chain: chainEnumFromName(wallet.Network),
TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress,
}
return &gatewayv1.ManagedWallet{
WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef,
OwnerRef: wallet.OwnerRef,
Asset: asset,
DepositAddress: wallet.DepositAddress,
Status: managedWalletStatusToProto(wallet.Status),
Metadata: cloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
}
}
func toProtoWalletBalance(balance *model.WalletBalance) *gatewayv1.WalletBalance {
if balance == nil {
return nil
}
return &gatewayv1.WalletBalance{
Available: cloneMoney(balance.Available),
PendingInbound: cloneMoney(balance.PendingInbound),
PendingOutbound: cloneMoney(balance.PendingOutbound),
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
}
}

View File

@@ -8,6 +8,9 @@ market:
- driver: COINGECKO
settings:
base_url: "https://api.coingecko.com/api/v3"
- driver: CBR
settings:
base_url: "https://www.cbr.ru"
pairs:
BINANCE:
- base: "USDT"
@@ -26,6 +29,15 @@ market:
- base: "USDT"
quote: "RUB"
symbol: "tether:rub"
CBR:
- base: "USD"
quote: "RUB"
symbol: "USD"
provider: "cbr"
- base: "EUR"
quote: "RUB"
symbol: "EUR"
provider: "cbr"
metrics:
enabled: true

View File

@@ -13,19 +13,20 @@ require (
github.com/tech/sendico/fx/storage v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -44,12 +45,11 @@ require (
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -59,8 +59,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -212,8 +212,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -7,12 +7,12 @@ import (
"github.com/tech/sendico/fx/ingestor/internal/appversion"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
"github.com/tech/sendico/fx/ingestor/internal/metrics"
mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
@@ -26,7 +26,7 @@ type App struct {
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
if logger == nil {
return nil, fmerrors.New("app: logger is nil")
return nil, merrors.InvalidArgument("app: logger is nil")
}
path := strings.TrimSpace(cfgPath)
if path == "" {

View File

@@ -5,9 +5,9 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"gopkg.in/yaml.v3"
)
@@ -25,33 +25,33 @@ type Config struct {
func Load(path string) (*Config, error) {
if path == "" {
return nil, fmerrors.New("config: path is empty")
return nil, merrors.InvalidArgument("config: path is empty")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmerrors.Wrap("config: failed to read file", err)
return nil, merrors.InternalWrap(err, "config: failed to read file")
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
}
if len(cfg.Market.Sources) == 0 {
return nil, fmerrors.New("config: no market sources configured")
return nil, merrors.InvalidArgument("config: no market sources configured")
}
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
for idx := range cfg.Market.Sources {
src := &cfg.Market.Sources[idx]
if src.Driver.IsEmpty() {
return nil, fmerrors.New("config: market source driver is empty")
return nil, merrors.InvalidArgument("config: market source driver is empty")
}
sourceSet[src.Driver] = struct{}{}
}
if len(cfg.Market.Pairs) == 0 {
return nil, fmerrors.New("config: no pairs configured")
return nil, merrors.InvalidArgument("config: no pairs configured")
}
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
@@ -61,10 +61,10 @@ func Load(path string) (*Config, error) {
for rawSource, pairList := range cfg.Market.Pairs {
driver := mmodel.Driver(rawSource)
if driver.IsEmpty() {
return nil, fmerrors.New("config: pair source is empty")
return nil, merrors.InvalidArgument("config: pair source is empty")
}
if _, ok := sourceSet[driver]; !ok {
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
}
processed := make([]PairConfig, len(pairList))
@@ -74,7 +74,7 @@ func Load(path string) (*Config, error) {
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
pair.Symbol = strings.TrimSpace(pair.Symbol)
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
}
if strings.TrimSpace(pair.Provider) == "" {
pair.Provider = strings.ToLower(driver.String())
@@ -93,7 +93,7 @@ func Load(path string) (*Config, error) {
cfg.pairsBySource = pairsBySource
cfg.pairs = flattened
if cfg.Database == nil {
return nil, fmerrors.New("config: database configuration is required")
return nil, merrors.InvalidArgument("config: database configuration is required")
}
if cfg.Metrics != nil && cfg.Metrics.Enabled {

View File

@@ -1,35 +0,0 @@
package fmerrors
type Error struct {
message string
cause error
}
func (e *Error) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func New(message string) error {
return &Error{message: message}
}
func Wrap(message string, cause error) error {
return &Error{message: message, cause: cause}
}
func NewDecimal(value string) error {
return &Error{message: "invalid decimal \"" + value + "\""}
}

View File

@@ -6,11 +6,11 @@ import (
"time"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
@@ -26,18 +26,18 @@ type Service struct {
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
if logger == nil {
return nil, fmerrors.New("ingestor: nil logger")
return nil, merrors.InvalidArgument("ingestor: nil logger")
}
if cfg == nil {
return nil, fmerrors.New("ingestor: nil config")
return nil, merrors.InvalidArgument("ingestor: nil config")
}
if repo == nil {
return nil, fmerrors.New("ingestor: nil repository")
return nil, merrors.InvalidArgument("ingestor: nil repository")
}
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
if err != nil {
return nil, fmerrors.Wrap("build connectors", err)
return nil, merrors.InternalWrap(err, "build connectors")
}
return &Service{
@@ -110,21 +110,21 @@ func (s *Service) pollOnce(ctx context.Context) error {
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
connector, ok := s.connectors[pair.Source]
if !ok {
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
return merrors.InvalidArgument("connector not configured for source "+pair.Source.String(), "source")
}
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil {
return fmerrors.Wrap("fetch ticker", err)
return merrors.InternalWrap(err, "fetch ticker")
}
bid, err := parseDecimal(ticker.BidPrice)
if err != nil {
return fmerrors.Wrap("parse bid price", err)
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
}
ask, err := parseDecimal(ticker.AskPrice)
if err != nil {
return fmerrors.Wrap("parse ask price", err)
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
}
if pair.Invert {
@@ -166,7 +166,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
}
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
return fmerrors.Wrap("upsert snapshot", err)
return merrors.InternalWrap(err, "upsert snapshot")
}
s.logger.Debug("Snapshot ingested",
@@ -183,7 +183,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
func parseDecimal(value string) (*big.Rat, error) {
r := new(big.Rat)
if _, ok := r.SetString(value); !ok {
return nil, fmerrors.NewDecimal(value)
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
}
return r, nil
}

View File

@@ -7,10 +7,10 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
@@ -131,7 +131,7 @@ func TestServiceUpsertPairInvertsPrices(t *testing.T) {
}
func TestServicePollOnceReturnsFirstError(t *testing.T) {
errFetch := fmerrors.New("fetch failed")
errFetch := merrors.Internal("fetch failed")
connectorSuccess := &connectorStub{
id: mmarket.DriverBinance,
ticker: &mmarket.Ticker{

View File

@@ -10,9 +10,9 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
@@ -60,7 +60,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmerrors.Wrap("binance: invalid base url", err)
return nil, merrors.InvalidArgumentWrap(err, "binance: invalid base url", "base_url")
}
transport := &http.Transport{
@@ -89,12 +89,12 @@ func (c *binanceConnector) ID() mmodel.Driver {
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
if strings.TrimSpace(symbol) == "" {
return nil, fmerrors.New("binance: symbol is empty")
return nil, merrors.InvalidArgument("binance: symbol is empty", "symbol")
}
endpoint, err := url.Parse(c.base)
if err != nil {
return nil, fmerrors.Wrap("binance: parse base url", err)
return nil, merrors.InternalWrap(err, "binance: parse base url")
}
endpoint.Path = "/api/v3/ticker/bookTicker"
query := endpoint.Query()
@@ -103,19 +103,19 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmerrors.Wrap("binance: build request", err)
return nil, merrors.InternalWrap(err, "binance: build request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("binance: request failed", err)
return nil, merrors.InternalWrap(err, "binance: request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
return nil, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
}
var payload struct {
@@ -126,7 +126,7 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("binance: decode response", err)
return nil, merrors.InternalWrap(err, "binance: decode response")
}
return &mmodel.Ticker{

View File

@@ -0,0 +1,537 @@
package cbr
import (
"context"
"encoding/xml"
"math/big"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
"golang.org/x/net/html/charset"
)
type cbrConnector struct {
id mmodel.Driver
provider string
client *http.Client
base string
dailyPath string
directoryPath string
dynamicPath string
logger mlogger.Logger
byISO map[string]valuteInfo
byID map[string]valuteInfo
}
const defaultCBRBaseURL = "https://www.cbr.ru"
const (
defaultDirectoryPath = "/scripts/XML_valFull.asp"
defaultDailyPath = "/scripts/XML_daily.asp"
defaultDynamicPath = "/scripts/XML_dynamic.asp"
)
const (
defaultDialTimeoutSeconds = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
baseURL := defaultCBRBaseURL
provider := strings.ToLower(mmodel.DriverCBR.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath
if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
baseURL = strings.TrimSpace(value)
}
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
provider = strings.TrimSpace(value)
}
if value, ok := settings["directory_path"].(string); ok && strings.TrimSpace(value) != "" {
directoryPath = strings.TrimSpace(value)
}
if value, ok := settings["daily_path"].(string); ok && strings.TrimSpace(value) != "" {
dailyPath = strings.TrimSpace(value)
}
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
}
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, merrors.InvalidArgumentWrap(err, "cbr: invalid base url", "base_url")
}
var transport http.RoundTripper = &http.Transport{
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ResponseHeaderTimeout: responseHeaderTimeout,
}
if customTransport, ok := settings["http_round_tripper"].(http.RoundTripper); ok && customTransport != nil {
transport = customTransport
}
connector := &cbrConnector{
id: mmodel.DriverCBR,
provider: provider,
client: &http.Client{
Timeout: requestTimeout,
Transport: transport,
},
base: strings.TrimRight(parsed.String(), "/"),
dailyPath: dailyPath,
directoryPath: directoryPath,
dynamicPath: dynamicPath,
logger: logger.Named("cbr"),
}
if err := connector.refreshDirectory(); err != nil {
return nil, err
}
return connector, nil
}
func (c *cbrConnector) ID() mmodel.Driver {
return c.id
}
func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
isoCode, asOfDate, err := parseSymbol(symbol)
if err != nil {
return nil, err
}
valute, ok := c.byISO[isoCode]
if !ok {
return nil, merrors.InvalidArgument("cbr: unknown currency "+isoCode, "symbol")
}
var price string
if asOfDate != nil {
price, err = c.fetchHistoricalRate(ctx, valute, *asOfDate)
} else {
price, err = c.fetchDailyRate(ctx, valute)
}
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
return &mmodel.Ticker{
Symbol: formatSymbol(isoCode, asOfDate),
BidPrice: price,
AskPrice: price,
Provider: c.provider,
Timestamp: now,
}, nil
}
func (c *cbrConnector) refreshDirectory() error {
endpoint, err := c.buildURL(c.directoryPath, nil)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return merrors.InternalWrap(err, "cbr: build directory request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CBR directory request failed", zap.Error(err))
return merrors.InternalWrap(err, "cbr: directory request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := xml.NewDecoder(resp.Body)
decoder.CharsetReader = charset.NewReaderLabel
var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err))
return merrors.InternalWrap(err, "cbr: decode directory")
}
mapping, err := buildValuteMapping(directory.Items)
if err != nil {
return err
}
c.byISO = mapping.byISO
c.byID = mapping.byID
return nil
}
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
endpoint, err := c.buildURL(c.dailyPath, nil)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: build daily request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: daily request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := xml.NewDecoder(resp.Body)
decoder.CharsetReader = charset.NewReaderLabel
var payload dailyRates
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: decode daily response")
}
entry := payload.find(valute.ID)
if entry == nil {
return "", merrors.NoData("cbr: currency not found in daily rates: " + valute.ISOCharCode)
}
if err := validateDailyEntry(valute, entry); err != nil {
return "", err
}
return computePrice(entry.Value, entry.Nominal)
}
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) {
query := map[string]string{
"date_req1": date.Format("02/01/2006"),
"date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID,
}
endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: build historical request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: historical request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := xml.NewDecoder(resp.Body)
decoder.CharsetReader = charset.NewReaderLabel
var payload dynamicRates
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR historical decode failed", zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: decode historical response")
}
record := payload.find(valute.ID, date)
if record == nil {
return "", merrors.NoData("cbr: historical rate not found for " + valute.ISOCharCode)
}
if record.Nominal != "" {
nominal, err := parseNominal(record.Nominal)
if err != nil {
return "", merrors.InvalidDataType(err.Error())
}
if nominal != valute.Nominal {
return "", merrors.Internal("cbr: historical nominal mismatch for " + valute.ISOCharCode)
}
}
return computePrice(record.Value, strconv.FormatInt(valute.Nominal, 10))
}
func (c *cbrConnector) buildURL(path string, query map[string]string) (string, error) {
base, err := url.Parse(c.base)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: parse base url")
}
base.Path = strings.TrimRight(base.Path, "/") + path
q := base.Query()
for key, value := range query {
q.Set(key, value)
}
base.RawQuery = q.Encode()
return base.String(), nil
}
type valuteDirectory struct {
Items []valuteItem `xml:"Item"`
}
type valuteItem struct {
ID string `xml:"ID,attr"`
ISOChar string `xml:"ISO_Char_Code"`
ISONum string `xml:"ISO_Num_Code"`
Name string `xml:"Name"`
EngName string `xml:"EngName"`
NominalStr string `xml:"Nominal"`
}
type valuteInfo struct {
ID string
ISOCharCode string
ISONumCode string
Name string
EngName string
Nominal int64
}
type valuteMapping struct {
byISO map[string]valuteInfo
byID map[string]valuteInfo
}
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items))
for _, item := range items {
id := strings.TrimSpace(item.ID)
isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name)
engName := strings.TrimSpace(item.EngName)
nominal, err := parseNominal(item.NominalStr)
if err != nil {
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
}
if id == "" || isoChar == "" {
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
}
info := valuteInfo{
ID: id,
ISOCharCode: isoChar,
ISONumCode: isoNum,
Name: name,
EngName: engName,
Nominal: nominal,
}
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
}
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
}
byISO[isoChar] = info
byID[id] = info
}
if len(byISO) == 0 {
return nil, merrors.InvalidDataType("cbr: empty directory received")
}
return &valuteMapping{
byISO: byISO,
byID: byID,
}, nil
}
type dailyRates struct {
Valutes []dailyValute `xml:"Valute"`
}
type dailyValute struct {
ID string `xml:"ID,attr"`
NumCode string `xml:"NumCode"`
CharCode string `xml:"CharCode"`
Nominal string `xml:"Nominal"`
Name string `xml:"Name"`
Value string `xml:"Value"`
}
func (d *dailyRates) find(id string) *dailyValute {
if d == nil {
return nil
}
for idx := range d.Valutes {
if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
return &d.Valutes[idx]
}
}
return nil
}
type dynamicRates struct {
Records []dynamicRecord `xml:"Record"`
}
type dynamicRecord struct {
ID string `xml:"Id,attr"`
DateRaw string `xml:"Date,attr"`
Nominal string `xml:"Nominal"`
Value string `xml:"Value"`
}
func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
if d == nil {
return nil
}
target := date.Format("02.01.2006")
for idx := range d.Records {
rec := &d.Records[idx]
if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
continue
}
if strings.TrimSpace(rec.DateRaw) == target {
return rec
}
}
return nil
}
func validateDailyEntry(expected valuteInfo, entry *dailyValute) error {
if entry == nil {
return merrors.NoData("cbr: missing daily entry")
}
if !strings.EqualFold(strings.TrimSpace(entry.CharCode), expected.ISOCharCode) {
return merrors.Internal("cbr: char code mismatch for " + expected.ISOCharCode)
}
if expected.ISONumCode != "" && strings.TrimSpace(entry.NumCode) != expected.ISONumCode {
return merrors.Internal("cbr: iso numeric mismatch for " + expected.ISOCharCode)
}
if expected.Name != "" && strings.TrimSpace(entry.Name) != expected.Name {
return merrors.Internal("cbr: currency name mismatch for " + expected.ISOCharCode)
}
nominal, err := parseNominal(entry.Nominal)
if err != nil {
return merrors.InvalidDataType("cbr: parse daily nominal: " + err.Error())
}
if nominal != expected.Nominal {
return merrors.Internal("cbr: nominal mismatch for " + expected.ISOCharCode)
}
return nil
}
func parseSymbol(symbol string) (string, *time.Time, error) {
trimmed := strings.TrimSpace(symbol)
if trimmed == "" {
return "", nil, merrors.InvalidArgument("cbr: symbol is empty", "symbol")
}
parts := strings.Split(trimmed, "@")
if len(parts) > 2 {
return "", nil, merrors.InvalidArgument("cbr: invalid symbol format", "symbol")
}
iso := strings.ToUpper(strings.TrimSpace(parts[0]))
if len(iso) != 3 {
return "", nil, merrors.InvalidArgument("cbr: symbol must be ISO currency code", "symbol")
}
if len(parts) == 1 {
return iso, nil, nil
}
datePart := strings.TrimSpace(parts[1])
if datePart == "" {
return "", nil, merrors.InvalidArgument("cbr: date component is empty", "symbol")
}
parsed, err := time.Parse("2006-01-02", datePart)
if err != nil {
return "", nil, merrors.InvalidArgumentWrap(err, "cbr: invalid date component", "symbol")
}
return iso, &parsed, nil
}
func parseNominal(value string) (int64, error) {
nominal, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || nominal <= 0 {
return 0, merrors.InvalidDataType("cbr: invalid nominal \"" + value + "\"")
}
return nominal, nil
}
func computePrice(value string, nominalStr string) (string, error) {
raw := strings.ReplaceAll(strings.TrimSpace(value), " ", "")
raw = strings.ReplaceAll(raw, ",", ".")
r := new(big.Rat)
if _, ok := r.SetString(raw); !ok {
return "", merrors.InvalidDataType("invalid decimal \"" + value + "\"")
}
nominal, err := parseNominal(nominalStr)
if err != nil {
return "", err
}
den := big.NewRat(nominal, 1)
price := new(big.Rat).Quo(r, den)
return price.FloatString(8), nil
}
func formatSymbol(iso string, asOf *time.Time) string {
if asOf == nil {
return iso
}
return iso + "@" + asOf.Format("2006-01-02")
}

View File

@@ -0,0 +1,226 @@
package cbr
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func TestFetchTickerDaily(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: dailyRatesXML},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
"request_timeout_seconds": 1,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
ticker, err := conn.FetchTicker(context.Background(), "USD")
if err != nil {
t.Fatalf("FetchTicker returned error: %v", err)
}
if ticker.Provider != "cbr" {
t.Fatalf("unexpected provider: %s", ticker.Provider)
}
if ticker.BidPrice != "95.12340000" || ticker.AskPrice != "95.12340000" {
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
}
if ticker.Symbol != "USD" {
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
}
}
func TestFetchTickerValidatesDailyEntry(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: strings.ReplaceAll(dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>")},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
if _, err := conn.FetchTicker(context.Background(), "USD"); err == nil {
t.Fatalf("FetchTicker expected to fail due to mismatch")
}
}
func TestFetchTickerHistorical(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_dynamic.asp": {
body: dynamicRatesXML,
check: func(r *http.Request) error {
if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" {
return fmt.Errorf("unexpected valute id: %s", got)
}
if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req1: %s", got)
}
if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req2: %s", got)
}
return nil
},
},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
ticker, err := conn.FetchTicker(context.Background(), "USD@2023-01-05")
if err != nil {
t.Fatalf("FetchTicker returned error: %v", err)
}
if ticker.BidPrice != "70.10000000" || ticker.AskPrice != "70.10000000" {
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
}
if ticker.Symbol != "USD@2023-01-05" {
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
}
}
func TestFetchTickerUnknownCurrency(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: dailyRatesXML},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
_, err = conn.FetchTicker(context.Background(), "ZZZ")
if err == nil {
t.Fatalf("FetchTicker expected to fail for unknown currency")
}
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error, got %v", err)
}
}
func TestFetchTickerRespectsCustomPaths(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/dir.xml": {body: valuteDirectoryXML},
"/rates.xml": {body: dailyRatesXML},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"directory_path": "/dir.xml",
"daily_path": "/rates.xml",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
if _, err := conn.FetchTicker(context.Background(), "USD"); err != nil {
t.Fatalf("FetchTicker returned error with custom paths: %v", err)
}
}
const valuteDirectoryXML = `
<Valuta name="Foreign Currency Market">
<Item ID="R01235">
<ISO_Num_Code>840</ISO_Num_Code>
<ISO_Char_Code>USD</ISO_Char_Code>
<Nominal>1</Nominal>
<Name>US Dollar</Name>
<EngName>US Dollar</EngName>
</Item>
</Valuta>`
const dailyRatesXML = `
<ValCurs Date="02.09.2024" name="Foreign Currency Market">
<Valute ID="R01235">
<NumCode>840</NumCode>
<CharCode>USD</CharCode>
<Nominal>1</Nominal>
<Name>US Dollar</Name>
<Value>95,1234</Value>
</Valute>
</ValCurs>`
const dynamicRatesXML = `
<ValCurs ID="R01235" DateRange1="05/01/2023" DateRange2="05/01/2023" name="Foreign Currency Market Dynamic">
<Record Date="05.01.2023" Id="R01235">
<Nominal>1</Nominal>
<Value>70,1</Value>
</Record>
</ValCurs>`
type stubResponse struct {
status int
body string
check func(*http.Request) error
}
type stubRoundTripper struct {
responses map[string]stubResponse
}
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if s.responses == nil {
return nil, fmt.Errorf("no responses configured")
}
res, ok := s.responses[req.URL.Path]
if !ok {
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path)
}
if res.check != nil {
if err := res.check(req); err != nil {
return nil, err
}
}
status := res.status
if status == 0 {
status = http.StatusOK
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(res.body)),
Header: http.Header{"Content-Type": []string{"text/xml"}},
Request: req,
}, nil
}

View File

@@ -10,9 +10,9 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
@@ -61,7 +61,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
return nil, merrors.InvalidArgumentWrap(err, "coingecko: invalid base url", "base_url")
}
transport := &http.Transport{
@@ -96,7 +96,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
endpoint, err := url.Parse(c.base)
if err != nil {
return nil, fmerrors.Wrap("coingecko: parse base url", err)
return nil, merrors.InternalWrap(err, "coingecko: parse base url")
}
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
query := endpoint.Query()
@@ -107,19 +107,19 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmerrors.Wrap("coingecko: build request", err)
return nil, merrors.InternalWrap(err, "coingecko: build request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("coingecko: request failed", err)
return nil, merrors.InternalWrap(err, "coingecko: request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
return nil, merrors.Internal("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := json.NewDecoder(resp.Body)
@@ -128,21 +128,21 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
var payload map[string]map[string]interface{}
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("coingecko: decode response", err)
return nil, merrors.InternalWrap(err, "coingecko: decode response")
}
coinData, ok := payload[coinID]
if !ok {
return nil, fmerrors.New("coingecko: coin id not found in response")
return nil, merrors.Internal("coingecko: coin id not found in response")
}
priceValue, ok := coinData[vsCurrency]
if !ok {
return nil, fmerrors.New("coingecko: vs currency not found in response")
return nil, merrors.Internal("coingecko: vs currency not found in response")
}
price, ok := toFloat(priceValue)
if !ok || price <= 0 {
return nil, fmerrors.New("coingecko: invalid price value in response")
return nil, merrors.Internal("coingecko: invalid price value in response")
}
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
@@ -171,7 +171,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
func parseSymbol(symbol string) (string, string, error) {
trimmed := strings.TrimSpace(symbol)
if trimmed == "" {
return "", "", fmerrors.New("coingecko: symbol is empty")
return "", "", merrors.InvalidArgument("coingecko: symbol is empty", "symbol")
}
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
@@ -183,13 +183,13 @@ func parseSymbol(symbol string) (string, string, error) {
})
if len(parts) != 2 {
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol")
}
coinID := strings.TrimSpace(parts[0])
vsCurrency := strings.TrimSpace(parts[1])
if coinID == "" || vsCurrency == "" {
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol")
}
return coinID, vsCurrency, nil

View File

@@ -5,10 +5,11 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
"github.com/tech/sendico/fx/ingestor/internal/market/cbr"
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
@@ -21,7 +22,7 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
for _, cfg := range configs {
driver := mmodel.NormalizeDriver(cfg.Driver)
if driver.IsEmpty() {
return nil, fmerrors.New("market: connector driver is empty")
return nil, merrors.InvalidArgument("market: connector driver is empty", "driver")
}
var (
@@ -34,12 +35,14 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
conn, err = binance.NewConnector(logger, cfg.Settings)
case mmodel.DriverCoinGecko:
conn, err = coingecko.NewConnector(logger, cfg.Settings)
case mmodel.DriverCBR:
conn, err = cbr.NewConnector(logger, cfg.Settings)
default:
err = fmerrors.New("market: unsupported driver " + driver.String())
err = merrors.InvalidArgument("market: unsupported driver "+driver.String(), "driver")
}
if err != nil {
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
return nil, merrors.InternalWrap(err, "market: build connector "+driver.String())
}
connectors[driver] = conn
}

View File

@@ -8,12 +8,12 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
@@ -30,7 +30,7 @@ type Server interface {
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
if logger == nil {
return nil, fmerrors.New("metrics: logger is nil")
return nil, merrors.InvalidArgument("metrics: logger is nil")
}
if cfg == nil || !cfg.Enabled {
logger.Debug("Metrics disabled; using noop server")

View File

@@ -10,6 +10,7 @@ type Driver string
const (
DriverBinance Driver = "BINANCE"
DriverCoinGecko Driver = "COINGECKO"
DriverCBR Driver = "CBR"
)
func (d Driver) String() string {

View File

@@ -21,13 +21,13 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -45,10 +45,10 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -59,8 +59,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -212,8 +212,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -12,12 +12,12 @@ require (
require (
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
@@ -25,8 +25,8 @@ require (
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

View File

@@ -7,8 +7,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -51,8 +51,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
@@ -138,8 +138,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -147,23 +147,23 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -4,11 +4,11 @@ root = "./../.."
tmp_dir = "tmp"
[build]
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/chain/gateway/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/chain/gateway/internal/appversion.BuildDate=$(date)'\""
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/gateway/chain/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.BuildDate=$(date)'\""
bin = "./app"
full_bin = "./app --debug --config.file=config.yml"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["chain/gateway/tmp", "pkg/.git", "chain/gateway/env"]
exclude_dir = ["gateway/chain/tmp", "pkg/.git", "gateway/chain/env"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = true

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/tech/sendico/pkg/merrors"
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
@@ -16,26 +16,26 @@ import (
// Client exposes typed helpers around the chain gateway gRPC API.
type Client interface {
CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error)
GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error)
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error)
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
Close() error
}
type grpcGatewayClient interface {
CreateManagedWallet(ctx context.Context, in *gatewayv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*gatewayv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, in *gatewayv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*gatewayv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, in *gatewayv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*gatewayv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, in *gatewayv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*gatewayv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, in *gatewayv1.SubmitTransferRequest, opts ...grpc.CallOption) (*gatewayv1.SubmitTransferResponse, error)
GetTransfer(ctx context.Context, in *gatewayv1.GetTransferRequest, opts ...grpc.CallOption) (*gatewayv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, in *gatewayv1.ListTransfersRequest, opts ...grpc.CallOption) (*gatewayv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, in *gatewayv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*gatewayv1.EstimateTransferFeeResponse, error)
CreateManagedWallet(ctx context.Context, in *chainv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, in *chainv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, in *chainv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, in *chainv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*chainv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, in *chainv1.SubmitTransferRequest, opts ...grpc.CallOption) (*chainv1.SubmitTransferResponse, 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)
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
}
type chainGatewayClient struct {
@@ -71,7 +71,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
return &chainGatewayClient{
cfg: cfg,
conn: conn,
client: gatewayv1.NewChainGatewayServiceClient(conn),
client: chainv1.NewChainGatewayServiceClient(conn),
}, nil
}
@@ -91,49 +91,49 @@ func (c *chainGatewayClient) Close() error {
return nil
}
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *gatewayv1.CreateManagedWalletRequest) (*gatewayv1.CreateManagedWalletResponse, error) {
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.CreateManagedWallet(ctx, req)
}
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *gatewayv1.GetManagedWalletRequest) (*gatewayv1.GetManagedWalletResponse, error) {
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetManagedWallet(ctx, req)
}
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *gatewayv1.ListManagedWalletsRequest) (*gatewayv1.ListManagedWalletsResponse, error) {
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ListManagedWallets(ctx, req)
}
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *gatewayv1.GetWalletBalanceRequest) (*gatewayv1.GetWalletBalanceResponse, error) {
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetWalletBalance(ctx, req)
}
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.SubmitTransfer(ctx, req)
}
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *gatewayv1.GetTransferRequest) (*gatewayv1.GetTransferResponse, error) {
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetTransfer(ctx, req)
}
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *gatewayv1.ListTransfersRequest) (*gatewayv1.ListTransfersResponse, error) {
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ListTransfers(ctx, req)
}
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *gatewayv1.EstimateTransferFeeRequest) (*gatewayv1.EstimateTransferFeeResponse, error) {
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.EstimateTransferFee(ctx, req)

View File

@@ -0,0 +1,83 @@
package client
import (
"context"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateManagedWalletFn func(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
GetManagedWalletFn func(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error)
ListManagedWalletsFn func(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalanceFn func(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error)
SubmitTransferFn func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error)
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
CloseFn func() error
}
func (f *Fake) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
if f.CreateManagedWalletFn != nil {
return f.CreateManagedWalletFn(ctx, req)
}
return &chainv1.CreateManagedWalletResponse{}, nil
}
func (f *Fake) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
if f.GetManagedWalletFn != nil {
return f.GetManagedWalletFn(ctx, req)
}
return &chainv1.GetManagedWalletResponse{}, nil
}
func (f *Fake) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
if f.ListManagedWalletsFn != nil {
return f.ListManagedWalletsFn(ctx, req)
}
return &chainv1.ListManagedWalletsResponse{}, nil
}
func (f *Fake) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
if f.GetWalletBalanceFn != nil {
return f.GetWalletBalanceFn(ctx, req)
}
return &chainv1.GetWalletBalanceResponse{}, nil
}
func (f *Fake) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
if f.SubmitTransferFn != nil {
return f.SubmitTransferFn(ctx, req)
}
return &chainv1.SubmitTransferResponse{}, nil
}
func (f *Fake) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
if f.GetTransferFn != nil {
return f.GetTransferFn(ctx, req)
}
return &chainv1.GetTransferResponse{}, nil
}
func (f *Fake) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
if f.ListTransfersFn != nil {
return f.ListTransfersFn(ctx, req)
}
return &chainv1.ListTransfersResponse{}, nil
}
func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
if f.EstimateTransferFeeFn != nil {
return f.EstimateTransferFeeFn(ctx, req)
}
return &chainv1.EstimateTransferFeeResponse{}, nil
}
func (f *Fake) Close() error {
if f.CloseFn != nil {
return f.CloseFn()
}
return nil
}

View File

@@ -54,4 +54,7 @@ key_management:
token_env: VAULT_TOKEN
namespace: ""
mount_path: kv
key_prefix: chain/gateway/wallets
key_prefix: gateway/chain/wallets
cache:
wallet_balance_ttl_seconds: 120

View File

@@ -1,4 +1,4 @@
module github.com/tech/sendico/chain/gateway
module github.com/tech/sendico/gateway/chain
go 1.25.3
@@ -22,11 +22,11 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@@ -54,7 +54,7 @@ require (
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -79,12 +79,12 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7 h1:uups37roJCTtR/BrJa0WoMrxt3rzgV+Qrj+TxYyJoAo=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -17,8 +17,8 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -159,8 +159,8 @@ github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -320,8 +320,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -329,12 +329,12 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -343,16 +343,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -362,8 +362,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -16,7 +16,7 @@ import (
"github.com/hashicorp/vault/api"
"go.uber.org/zap"
"github.com/tech/sendico/chain/gateway/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
)
@@ -65,7 +65,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
}
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
if keyPrefix == "" {
keyPrefix = "chain/gateway/wallets"
keyPrefix = "gateway/chain/wallets"
}
clientCfg := api.DefaultConfig()

View File

@@ -7,11 +7,12 @@ import (
"time"
"github.com/mitchellh/mapstructure"
"github.com/tech/sendico/chain/gateway/internal/keymanager"
vaultmanager "github.com/tech/sendico/chain/gateway/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/chain/gateway/internal/service/gateway"
"github.com/tech/sendico/chain/gateway/storage"
gatewaymongo "github.com/tech/sendico/chain/gateway/storage/mongo"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
@@ -33,9 +34,10 @@ type Imp struct {
type config struct {
*grpcapp.Config `yaml:",inline"`
Chains []chainConfig `yaml:"chains"`
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
KeyManagement keymanager.Config `yaml:"key_management"`
Chains []chainConfig `yaml:"chains"`
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
KeyManagement keymanager.Config `yaml:"key_management"`
Settings gatewayservice.CacheSettings `yaml:"cache"`
}
type chainConfig struct {
@@ -110,11 +112,12 @@ func (i *Imp) Start() error {
gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithTransferExecutor(executor),
gatewayservice.WithSettings(cfg.Settings),
}
return gatewayservice.NewService(logger, repo, producer, opts...), nil
}
app, err := grpcapp.NewApp(i.logger, "chain_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
@@ -154,8 +157,8 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil
}
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayservice.Network {
result := make([]gatewayservice.Network, 0, len(chains))
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" {
logger.Warn("skipping unnamed chain configuration")
@@ -165,7 +168,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
if rpcURL == "" {
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
}
contracts := make([]gatewayservice.TokenContract, 0, len(chain.Tokens))
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
for _, token := range chain.Tokens {
symbol := strings.TrimSpace(token.Symbol)
if symbol == "" {
@@ -185,13 +188,13 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
}
continue
}
contracts = append(contracts, gatewayservice.TokenContract{
contracts = append(contracts, gatewayshared.TokenContract{
Symbol: symbol,
ContractAddress: addr,
})
}
result = append(result, gatewayservice.Network{
result = append(result, gatewayshared.Network{
Name: chain.Name,
RPCURL: rpcURL,
ChainID: chain.ChainID,
@@ -202,7 +205,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
return result
}
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayservice.ServiceWallet {
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
address := strings.TrimSpace(cfg.Address)
if address == "" && cfg.AddressEnv != "" {
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
@@ -221,7 +224,7 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
}
return gatewayservice.ServiceWallet{
return gatewayshared.ServiceWallet{
Network: cfg.Chain,
Address: address,
PrivateKey: privateKey,

View File

@@ -1,7 +1,7 @@
package server
import (
serverimp "github.com/tech/sendico/chain/gateway/internal/server/internal"
serverimp "github.com/tech/sendico/gateway/chain/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)

View File

@@ -0,0 +1,44 @@
package commands
import (
"context"
"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/pkg/api/routers/gsresponse"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
type Unary[TReq any, TResp any] interface {
Execute(context.Context, *TReq) gsresponse.Responder[TResp]
}
type Registry struct {
CreateManagedWallet Unary[chainv1.CreateManagedWalletRequest, chainv1.CreateManagedWalletResponse]
GetManagedWallet Unary[chainv1.GetManagedWalletRequest, chainv1.GetManagedWalletResponse]
ListManagedWallets Unary[chainv1.ListManagedWalletsRequest, chainv1.ListManagedWalletsResponse]
GetWalletBalance Unary[chainv1.GetWalletBalanceRequest, chainv1.GetWalletBalanceResponse]
SubmitTransfer Unary[chainv1.SubmitTransferRequest, chainv1.SubmitTransferResponse]
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
}
type RegistryDeps struct {
Wallet wallet.Deps
Transfer transfer.Deps
}
func NewRegistry(deps RegistryDeps) Registry {
return Registry{
CreateManagedWallet: wallet.NewCreateManagedWallet(deps.Wallet.WithLogger("wallet.create")),
GetManagedWallet: wallet.NewGetManagedWallet(deps.Wallet.WithLogger("wallet.get")),
ListManagedWallets: wallet.NewListManagedWallets(deps.Wallet.WithLogger("wallet.list")),
GetWalletBalance: wallet.NewGetWalletBalance(deps.Wallet.WithLogger("wallet.balance")),
SubmitTransfer: transfer.NewSubmitTransfer(deps.Transfer.WithLogger("transfer.submit")),
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
}
}

View File

@@ -0,0 +1,43 @@
package transfer
import (
"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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func convertFees(fees []*chainv1.ServiceFeeBreakdown, currency string) ([]model.ServiceFee, decimal.Decimal, error) {
result := make([]model.ServiceFee, 0, len(fees))
sum := decimal.NewFromInt(0)
for _, fee := range fees {
if fee == nil || fee.GetAmount() == nil {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
}
amtCurrency := strings.ToUpper(strings.TrimSpace(fee.GetAmount().GetCurrency()))
if amtCurrency != strings.ToUpper(currency) {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee currency mismatch")
}
amtValue := strings.TrimSpace(fee.GetAmount().GetAmount())
if amtValue == "" {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required")
}
dec, err := decimal.NewFromString(amtValue)
if err != nil {
return nil, decimal.Decimal{}, merrors.InvalidArgument("invalid fee amount")
}
if dec.IsNegative() {
return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount must be non-negative")
}
sum = sum.Add(dec)
result = append(result, model.ServiceFee{
FeeCode: strings.TrimSpace(fee.GetFeeCode()),
Amount: shared.CloneMoney(fee.GetAmount()),
Description: strings.TrimSpace(fee.GetDescription()),
})
}
return result, sum, nil
}

View File

@@ -0,0 +1,26 @@
package transfer
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger"
)
type Deps struct {
Logger mlogger.Logger
Networks map[string]shared.Network
Storage storage.Repository
Clock clockpkg.Clock
EnsureRepository func(context.Context) error
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
}
func (d Deps) WithLogger(name string) Deps {
if d.Logger != nil {
d.Logger = d.Logger.Named(name)
}
return d
}

View File

@@ -0,0 +1,50 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) {
if dest == nil {
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
}
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
external := strings.TrimSpace(dest.GetExternalAddress())
if managedRef != "" && external != "" {
deps.Logger.Warn("both managed and external destination provided")
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
}
if managedRef != "" {
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
if err != nil {
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
return model.TransferDestination{}, err
}
if !strings.EqualFold(wallet.Network, source.Network) {
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
}
if strings.TrimSpace(wallet.DepositAddress) == "" {
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
}
return model.TransferDestination{
ManagedWalletRef: wallet.WalletRef,
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil
}
if external == "" {
deps.Logger.Warn("destination external address missing")
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
}
return model.TransferDestination{
ExternalAddress: strings.ToLower(external),
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil
}

View File

@@ -0,0 +1,26 @@
package transfer
import (
"context"
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
if err != nil {
return "", err
}
if strings.TrimSpace(wallet.DepositAddress) == "" {
return "", merrors.Internal("destination wallet missing deposit address")
}
return wallet.DepositAddress, nil
}
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
return strings.ToLower(addr), nil
}
return "", merrors.InvalidArgument("transfer destination address not resolved")
}

View File

@@ -0,0 +1,248 @@
package transfer
import (
"context"
"errors"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/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 estimateTransferFeeCommand struct {
deps Deps
}
func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
return &estimateTransferFeeCommand{deps: deps}
}
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
c.deps.Logger.Warn("source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
amount := req.GetAmount()
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
c.deps.Logger.Warn("amount missing or incomplete")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
}
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks[networkKey]
if !ok {
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"))
}
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
if err != nil {
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
if err != nil {
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
resp := &chainv1.EstimateTransferFeeResponse{
NetworkFee: feeMoney,
EstimationContext: "erc20_transfer",
}
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"
}
]`

View File

@@ -0,0 +1,47 @@
package transfer
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type getTransferCommand struct {
deps Deps
}
func NewGetTransfer(deps Deps) *getTransferCommand {
return &getTransferCommand{deps: deps}
}
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
transferRef := strings.TrimSpace(req.GetTransferRef())
if transferRef == "" {
c.deps.Logger.Warn("transfer_ref missing")
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
}
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef))
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
}

View File

@@ -0,0 +1,58 @@
package transfer
import (
"context"
"strings"
"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/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type listTransfersCommand struct {
deps Deps
}
func NewListTransfers(deps Deps) *listTransfersCommand {
return &listTransfersCommand{deps: deps}
}
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
}
filter := model.TransferFilter{}
if req != nil {
filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef())
if status := shared.TransferStatusToModel(req.GetStatus()); status != "" {
filter.Status = status
}
if page := req.GetPage(); page != nil {
filter.Cursor = strings.TrimSpace(page.GetCursor())
filter.Limit = page.GetLimit()
}
}
result, err := c.deps.Storage.Transfers().List(ctx, filter)
if err != nil {
c.deps.Logger.Warn("storage list failed", zap.Error(err))
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
}
protoTransfers := make([]*chainv1.Transfer, 0, len(result.Items))
for _, transfer := range result.Items {
protoTransfers = append(protoTransfers, toProtoTransfer(transfer))
}
resp := &chainv1.ListTransfersResponse{
Transfers: protoTransfers,
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
}
return gsresponse.Success(resp)
}

View File

@@ -0,0 +1,53 @@
package transfer
import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer {
if transfer == nil {
return nil
}
destination := &chainv1.TransferDestination{}
if transfer.Destination.ManagedWalletRef != "" {
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef}
} else if transfer.Destination.ExternalAddress != "" {
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress}
}
destination.Memo = transfer.Destination.Memo
protoFees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
for _, fee := range transfer.Fees {
protoFees = append(protoFees, &chainv1.ServiceFeeBreakdown{
FeeCode: fee.FeeCode,
Amount: shared.CloneMoney(fee.Amount),
Description: fee.Description,
})
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(transfer.Network),
TokenSymbol: transfer.TokenSymbol,
ContractAddress: transfer.ContractAddress,
}
return &chainv1.Transfer{
TransferRef: transfer.TransferRef,
IdempotencyKey: transfer.IdempotencyKey,
OrganizationRef: transfer.OrganizationRef,
SourceWalletRef: transfer.SourceWalletRef,
Destination: destination,
Asset: asset,
RequestedAmount: shared.CloneMoney(transfer.RequestedAmount),
NetAmount: shared.CloneMoney(transfer.NetAmount),
Fees: protoFees,
Status: shared.TransferStatusToProto(transfer.Status),
TransactionHash: transfer.TxHash,
FailureReason: transfer.FailureReason,
CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()),
}
}

View File

@@ -0,0 +1,148 @@
package transfer
import (
"context"
"errors"
"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/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type submitTransferCommand struct {
deps Deps
}
func NewSubmitTransfer(deps Deps) *submitTransferCommand {
return &submitTransferCommand{deps: deps}
}
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
c.deps.Logger.Warn("missing idempotency key")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
c.deps.Logger.Warn("missing organization ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
c.deps.Logger.Warn("missing source wallet ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
amount := req.GetAmount()
if amount == nil {
c.deps.Logger.Warn("missing amount")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
}
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
if amountCurrency == "" {
c.deps.Logger.Warn("missing amount currency")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
}
amountValue := strings.TrimSpace(amount.GetAmount())
if amountValue == "" {
c.deps.Logger.Warn("missing amount value")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
}
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks[networkKey]
if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
if err != nil {
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
amountDec, err := decimal.NewFromString(amountValue)
if err != nil {
c.deps.Logger.Warn("invalid amount", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
}
netDec := amountDec.Sub(feeSum)
if netDec.IsNegative() {
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
}
netAmount := shared.CloneMoney(amount)
netAmount.Amount = netDec.String()
transfer := &model.Transfer{
IdempotencyKey: idempotencyKey,
TransferRef: shared.GenerateTransferRef(),
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
Destination: destination,
Network: sourceWallet.Network,
TokenSymbol: sourceWallet.TokenSymbol,
ContractAddress: sourceWallet.ContractAddress,
RequestedAmount: shared.CloneMoney(amount),
NetAmount: netAmount,
Fees: fees,
Status: model.TransferStatusPending,
ClientReference: strings.TrimSpace(req.GetClientReference()),
LastStatusAt: c.deps.Clock.Now().UTC(),
}
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
}
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if c.deps.LaunchExecution != nil {
c.deps.LaunchExecution(saved.TransferRef, sourceWalletRef, networkCfg)
}
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
}

View File

@@ -0,0 +1,140 @@
package wallet
import (
"context"
"errors"
"strings"
"time"
"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/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"
"google.golang.org/protobuf/types/known/timestamppb"
)
const fallbackBalanceCacheTTL = 2 * time.Minute
type getWalletBalanceCommand struct {
deps Deps
}
func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
return &getWalletBalanceCommand{deps: deps}
}
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" {
c.deps.Logger.Warn("wallet_ref missing")
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
}
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
if chainErr != nil {
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
}
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if c.isCachedBalanceStale(stored) {
c.deps.Logger.Warn("cached balance is stale",
zap.String("wallet_ref", walletRef),
zap.Time("calculated_at", stored.CalculatedAt),
zap.Duration("ttl", c.cacheTTL()),
)
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
}
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
}
calculatedAt := c.now()
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
Balance: onChainBalanceToProto(balance, calculatedAt),
})
}
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
if balance == nil {
return nil
}
zero := zeroMoney(balance.Currency)
return &chainv1.WalletBalance{
Available: balance,
PendingInbound: zero,
PendingOutbound: zero,
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
}
}
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
if available == nil {
return
}
record := &model.WalletBalance{
WalletRef: walletRef,
Available: shared.CloneMoney(available),
PendingInbound: zeroMoney(available.Currency),
PendingOutbound: zeroMoney(available.Currency),
CalculatedAt: calculatedAt,
}
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))
}
}
func (c *getWalletBalanceCommand) isCachedBalanceStale(balance *model.WalletBalance) bool {
if balance == nil || balance.CalculatedAt.IsZero() {
return true
}
return c.now().After(balance.CalculatedAt.Add(c.cacheTTL()))
}
func (c *getWalletBalanceCommand) cacheTTL() time.Duration {
if c.deps.BalanceCacheTTL > 0 {
return c.deps.BalanceCacheTTL
}
// Fallback to sane default if not configured.
return fallbackBalanceCacheTTL
}
func (c *getWalletBalanceCommand) now() time.Time {
if c.deps.Clock != nil {
return c.deps.Clock.Now().UTC()
}
return time.Now().UTC()
}
func zeroMoney(currency string) *moneyv1.Money {
if strings.TrimSpace(currency) == "" {
return nil
}
return &moneyv1.Money{Currency: currency, Amount: "0"}
}

View File

@@ -0,0 +1,123 @@
package wallet
import (
"context"
"errors"
"strings"
"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/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type createManagedWalletCommand struct {
deps Deps
}
func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
return &createManagedWalletCommand{deps: deps}
}
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
c.deps.Logger.Warn("missing idempotency key")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
c.deps.Logger.Warn("missing organization ref")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
ownerRef := strings.TrimSpace(req.GetOwnerRef())
if ownerRef == "" {
c.deps.Logger.Warn("missing owner ref")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
}
asset := req.GetAsset()
if asset == nil {
c.deps.Logger.Warn("missing asset")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
}
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
if chainKey == "" {
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
networkCfg, ok := c.deps.Networks[chainKey]
if !ok {
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"))
}
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if tokenSymbol == "" {
c.deps.Logger.Warn("missing token symbol")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
}
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
if contractAddress == "" {
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
if contractAddress == "" {
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
}
}
walletRef := shared.GenerateWalletRef()
if c.deps.KeyManager == nil {
c.deps.Logger.Warn("key manager missing")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
}
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
if err != nil {
c.deps.Logger.Warn("key manager error", zap.Error(err))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
c.deps.Logger.Warn("key manager returned empty address")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
}
wallet := &model.ManagedWallet{
IdempotencyKey: idempotencyKey,
WalletRef: walletRef,
OrganizationRef: organizationRef,
OwnerRef: ownerRef,
Network: chainKey,
TokenSymbol: tokenSymbol,
ContractAddress: contractAddress,
DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive,
Metadata: shared.CloneMetadata(req.GetMetadata()),
}
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
}
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
}

View File

@@ -0,0 +1,29 @@
package wallet
import (
"context"
"time"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger"
)
type Deps struct {
Logger mlogger.Logger
Networks map[string]shared.Network
KeyManager keymanager.Manager
Storage storage.Repository
Clock clockpkg.Clock
BalanceCacheTTL time.Duration
EnsureRepository func(context.Context) error
}
func (d Deps) WithLogger(name string) Deps {
if d.Logger != nil {
d.Logger = d.Logger.Named(name)
}
return d
}

View File

@@ -0,0 +1,47 @@
package wallet
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type getManagedWalletCommand struct {
deps Deps
}
func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
return &getManagedWalletCommand{deps: deps}
}
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
}
walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" {
c.deps.Logger.Warn("wallet_ref missing")
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
}
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
}

View File

@@ -0,0 +1,59 @@
package wallet
import (
"context"
"strings"
"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/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type listManagedWalletsCommand struct {
deps Deps
}
func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
return &listManagedWalletsCommand{deps: deps}
}
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
}
filter := model.ManagedWalletFilter{}
if req != nil {
filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
filter.OwnerRef = strings.TrimSpace(req.GetOwnerRef())
if asset := req.GetAsset(); asset != nil {
filter.Network, _ = shared.ChainKeyFromEnum(asset.GetChain())
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
}
if page := req.GetPage(); page != nil {
filter.Cursor = strings.TrimSpace(page.GetCursor())
filter.Limit = page.GetLimit()
}
}
result, err := c.deps.Storage.Wallets().List(ctx, filter)
if err != nil {
c.deps.Logger.Warn("storage list failed", zap.Error(err))
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
}
protoWallets := make([]*chainv1.ManagedWallet, 0, len(result.Items))
for _, wallet := range result.Items {
protoWallets = append(protoWallets, toProtoManagedWallet(wallet))
}
resp := &chainv1.ListManagedWalletsResponse{
Wallets: protoWallets,
Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor},
}
return gsresponse.Success(resp)
}

View File

@@ -0,0 +1,124 @@
package wallet
import (
"context"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.Internal("network rpc url is not configured")
}
contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" || !common.IsHexAddress(contract) {
return nil, merrors.InvalidArgument("invalid contract address")
}
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
return nil, merrors.InvalidArgument("invalid wallet address")
}
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(contract)
walletAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
return nil, err
}
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
if err != nil {
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
data, err := tokenABI.Pack("decimals")
if err != nil {
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", out)
if err != nil || len(values) == 0 {
return 0, merrors.Internal("failed to unpack decimals")
}
if val, ok := values[0].(uint8); ok {
return val, nil
}
return 0, merrors.Internal("decimals returned unexpected type")
}
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
data, err := tokenABI.Pack("balanceOf", wallet)
if err != nil {
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
values, err := tokenABI.Unpack("balanceOf", out)
if err != nil || len(values) == 0 {
return nil, merrors.Internal("failed to unpack balanceOf")
}
raw, ok := values[0].(*big.Int)
if !ok {
return nil, merrors.Internal("balanceOf returned unexpected type")
}
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
}
const erc20ABIJSON = `
[
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [{ "name": "_owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "balance", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]`

View File

@@ -0,0 +1,42 @@
package wallet
import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
if wallet == nil {
return nil
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(wallet.Network),
TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress,
}
return &chainv1.ManagedWallet{
WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef,
OwnerRef: wallet.OwnerRef,
Asset: asset,
DepositAddress: wallet.DepositAddress,
Status: shared.ManagedWalletStatusToProto(wallet.Status),
Metadata: shared.CloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
}
}
func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
if balance == nil {
return nil
}
return &chainv1.WalletBalance{
Available: shared.CloneMoney(balance.Available),
PendingInbound: shared.CloneMoney(balance.PendingInbound),
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
}
}

View File

@@ -14,18 +14,19 @@ import (
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"go.uber.org/zap"
"github.com/tech/sendico/chain/gateway/internal/keymanager"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
)
// TransferExecutor handles on-chain submission of transfers.
type TransferExecutor interface {
SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error)
AwaitConfirmation(ctx context.Context, network Network, txHash string) (*types.Receipt, error)
SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error)
AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error)
}
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
@@ -45,7 +46,7 @@ type onChainExecutor struct {
clients map[string]*ethclient.Client
}
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network 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 {
o.logger.Error("key manager not configured")
return "", executorInternal("key manager is not configured", nil)
@@ -237,7 +238,7 @@ func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethcli
return c, nil
}
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network 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) == "" {
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
return nil, executorInvalid("tx hash is required")

View File

@@ -3,35 +3,14 @@ package gateway
import (
"strings"
"github.com/tech/sendico/chain/gateway/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
clockpkg "github.com/tech/sendico/pkg/clock"
)
// Option configures the Service.
type Option func(*Service)
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name string
RPCURL string
ChainID uint64
NativeToken string
TokenConfigs []TokenContract
}
// TokenContract captures the metadata needed to work with a specific on-chain token.
type TokenContract struct {
Symbol string
ContractAddress string
}
// ServiceWallet captures the managed service wallet configuration.
type ServiceWallet struct {
Network string
Address string
PrivateKey string
}
// WithKeyManager configures the service key manager.
func WithKeyManager(manager keymanager.Manager) Option {
return func(s *Service) {
@@ -47,13 +26,13 @@ func WithTransferExecutor(executor TransferExecutor) Option {
}
// WithNetworks configures supported blockchain networks.
func WithNetworks(networks []Network) Option {
func WithNetworks(networks []shared.Network) Option {
return func(s *Service) {
if len(networks) == 0 {
return
}
if s.networks == nil {
s.networks = make(map[string]Network, len(networks))
s.networks = make(map[string]shared.Network, len(networks))
}
for _, network := range networks {
if network.Name == "" {
@@ -61,7 +40,7 @@ func WithNetworks(networks []Network) Option {
}
clone := network
if clone.TokenConfigs == nil {
clone.TokenConfigs = []TokenContract{}
clone.TokenConfigs = []shared.TokenContract{}
}
for i := range clone.TokenConfigs {
clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol))
@@ -74,7 +53,7 @@ func WithNetworks(networks []Network) Option {
}
// WithServiceWallet configures the service wallet binding.
func WithServiceWallet(wallet ServiceWallet) Option {
func WithServiceWallet(wallet shared.ServiceWallet) Option {
return func(s *Service) {
s.serviceWallet = wallet
}
@@ -88,3 +67,10 @@ func WithClock(clk clockpkg.Clock) Option {
}
}
}
// WithSettings applies gateway settings.
func WithSettings(settings CacheSettings) Option {
return func(s *Service) {
s.settings = settings.withDefaults()
}
}

View File

@@ -0,0 +1,159 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/grpc"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
var (
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
)
// Service implements the ChainGatewayService RPC contract.
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
clock clockpkg.Clock
settings CacheSettings
networks map[string]shared.Network
serviceWallet shared.ServiceWallet
keyManager keymanager.Manager
executor TransferExecutor
commands commands.Registry
chainv1.UnimplementedChainGatewayServiceServer
}
// NewService constructs the chain gateway service skeleton.
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("service"),
storage: repo,
producer: producer,
clock: clockpkg.System{},
settings: defaultSettings(),
networks: map[string]shared.Network{},
}
initMetrics()
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
if svc.clock == nil {
svc.clock = clockpkg.System{}
}
if svc.networks == nil {
svc.networks = map[string]shared.Network{}
}
svc.settings = svc.settings.withDefaults()
svc.commands = commands.NewRegistry(commands.RegistryDeps{
Wallet: commandsWalletDeps(svc),
Transfer: commandsTransferDeps(svc),
})
return svc
}
// Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
chainv1.RegisterChainGatewayServiceServer(reg, s)
})
}
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
}
func (s *Service) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
return executeUnary(ctx, s, "GetManagedWallet", s.commands.GetManagedWallet.Execute, req)
}
func (s *Service) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
return executeUnary(ctx, s, "ListManagedWallets", s.commands.ListManagedWallets.Execute, req)
}
func (s *Service) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
return executeUnary(ctx, s, "GetWalletBalance", s.commands.GetWalletBalance.Execute, req)
}
func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
return executeUnary(ctx, s, "SubmitTransfer", s.commands.SubmitTransfer.Execute, req)
}
func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
return executeUnary(ctx, s, "GetTransfer", s.commands.GetTransfer.Execute, req)
}
func (s *Service) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
return executeUnary(ctx, s, "ListTransfers", s.commands.ListTransfers.Execute, req)
}
func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
}
func (s *Service) ensureRepository(ctx context.Context) error {
if s.storage == nil {
return errStorageUnavailable
}
return s.storage.Ping(ctx)
}
func commandsWalletDeps(s *Service) wallet.Deps {
return wallet.Deps{
Logger: s.logger.Named("command"),
Networks: s.networks,
KeyManager: s.keyManager,
Storage: s.storage,
Clock: s.clock,
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
EnsureRepository: s.ensureRepository,
}
}
func commandsTransferDeps(s *Service) transfer.Deps {
return transfer.Deps{
Logger: s.logger.Named("transfer_cmd"),
Networks: s.networks,
Storage: s.storage,
Clock: s.clock,
EnsureRepository: s.ensureRepository,
LaunchExecution: s.launchTransferExecution,
}
}
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
start := svc.clock.Now()
resp, err := gsresponse.Unary(svc.logger, mservice.ChainGateway, handler)(ctx, req)
observeRPC(method, err, svc.clock.Now().Sub(start))
return resp, err
}

View File

@@ -11,15 +11,16 @@ import (
"time"
"github.com/stretchr/testify/require"
igatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
ichainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/tech/sendico/chain/gateway/internal/keymanager"
"github.com/tech/sendico/chain/gateway/storage"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
@@ -40,12 +41,12 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
svc, repo := newTestService(t)
ctx := context.Background()
req := &igatewayv1.CreateManagedWalletRequest{
req := &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-1",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &igatewayv1.Asset{
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "USDC",
},
}
@@ -69,12 +70,12 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
ctx := context.Background()
// create source wallet
srcResp, err := svc.CreateManagedWallet(ctx, &igatewayv1.CreateManagedWalletRequest{
srcResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-src",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &igatewayv1.Asset{
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "USDC",
},
})
@@ -82,27 +83,27 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
srcRef := srcResp.GetWallet().GetWalletRef()
// destination wallet
dstResp, err := svc.CreateManagedWallet(ctx, &igatewayv1.CreateManagedWalletRequest{
dstResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-dst",
OrganizationRef: "org-1",
OwnerRef: "owner-2",
Asset: &igatewayv1.Asset{
Chain: igatewayv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "USDC",
},
})
require.NoError(t, err)
dstRef := dstResp.GetWallet().GetWalletRef()
transferResp, err := svc.SubmitTransfer(ctx, &igatewayv1.SubmitTransferRequest{
transferResp, err := svc.SubmitTransfer(ctx, &ichainv1.SubmitTransferRequest{
IdempotencyKey: "transfer-1",
OrganizationRef: "org-1",
SourceWalletRef: srcRef,
Destination: &igatewayv1.TransferDestination{
Destination: &igatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef},
Destination: &ichainv1.TransferDestination{
Destination: &ichainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef},
},
Amount: &moneyv1.Money{Currency: "USDC", Amount: "100"},
Fees: []*igatewayv1.ServiceFeeBreakdown{
Fees: []*ichainv1.ServiceFeeBreakdown{
{
FeeCode: "service",
Amount: &moneyv1.Money{Currency: "USDC", Amount: "5"},
@@ -118,12 +119,12 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
require.Equal(t, model.TransferStatusPending, stored.Status)
// GetTransfer
getResp, err := svc.GetTransfer(ctx, &igatewayv1.GetTransferRequest{TransferRef: stored.TransferRef})
getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef})
require.NoError(t, err)
require.Equal(t, stored.TransferRef, getResp.GetTransfer().GetTransferRef())
// ListTransfers
listResp, err := svc.ListTransfers(ctx, &igatewayv1.ListTransfersRequest{
listResp, err := svc.ListTransfers(ctx, &ichainv1.ListTransfersRequest{
SourceWalletRef: srcRef,
Page: &paginationv1.CursorPageRequest{Limit: 10},
})
@@ -136,7 +137,7 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
_, err := svc.GetWalletBalance(ctx, &igatewayv1.GetWalletBalanceRequest{WalletRef: "missing"})
_, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: "missing"})
require.Error(t, err)
st, _ := status.FromError(err)
require.Equal(t, codes.NotFound, st.Code())
@@ -530,13 +531,13 @@ func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
logger := zap.NewNop()
svc := NewService(logger, repo, nil,
WithKeyManager(&fakeKeyManager{}),
WithNetworks([]Network{{
WithNetworks([]shared.Network{{
Name: "ethereum_mainnet",
TokenConfigs: []TokenContract{
TokenConfigs: []shared.TokenContract{
{Symbol: "USDC", ContractAddress: "0xusdc"},
},
}}),
WithServiceWallet(ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
)
return svc, repo
}

View File

@@ -0,0 +1,30 @@
package gateway
import "time"
const defaultWalletBalanceCacheTTL = 120 * time.Second
// CacheSettings holds tunable gateway behaviour.
type CacheSettings struct {
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
}
func defaultSettings() CacheSettings {
return CacheSettings{
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
}
}
func (s CacheSettings) withDefaults() CacheSettings {
if s.WalletBalanceCacheTTLSeconds <= 0 {
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
}
return s
}
func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
if s.WalletBalanceCacheTTLSeconds <= 0 {
return defaultWalletBalanceCacheTTL
}
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
}

View File

@@ -0,0 +1,142 @@
package shared
import (
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// CloneMoney defensively copies a Money proto.
func CloneMoney(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()}
}
// CloneMetadata defensively copies metadata maps.
func CloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
clone := make(map[string]string, len(input))
for k, v := range input {
clone[k] = v
}
return clone
}
// ResolveContractAddress finds a token contract for a symbol in a case-insensitive manner.
func ResolveContractAddress(tokens []TokenContract, symbol string) string {
upper := strings.ToUpper(symbol)
for _, token := range tokens {
if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" {
return strings.ToLower(token.ContractAddress)
}
}
return ""
}
func GenerateWalletRef() string {
return primitive.NewObjectID().Hex()
}
func GenerateTransferRef() string {
return primitive.NewObjectID().Hex()
}
func ChainKeyFromEnum(chain chainv1.ChainNetwork) (string, chainv1.ChainNetwork) {
if name, ok := chainv1.ChainNetwork_name[int32(chain)]; ok {
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
return key, chain
}
return "", chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ChainEnumFromName(name string) chainv1.ChainNetwork {
if name == "" {
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
upper := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(name, " ", "_"), "-", "_"))
key := "CHAIN_NETWORK_" + upper
if val, ok := chainv1.ChainNetwork_value[key]; ok {
return chainv1.ChainNetwork(val)
}
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.ManagedWalletStatus {
switch status {
case model.ManagedWalletStatusActive:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
case model.ManagedWalletStatusSuspended:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
case model.ManagedWalletStatusClosed:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
default:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
}
}
func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_PENDING:
return model.TransferStatusPending
case chainv1.TransferStatus_TRANSFER_SIGNING:
return model.TransferStatusSigning
case chainv1.TransferStatus_TRANSFER_SUBMITTED:
return model.TransferStatusSubmitted
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return model.TransferStatusConfirmed
case chainv1.TransferStatus_TRANSFER_FAILED:
return model.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return model.TransferStatusCancelled
default:
return ""
}
}
func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
switch status {
case model.TransferStatusPending:
return chainv1.TransferStatus_TRANSFER_PENDING
case model.TransferStatusSigning:
return chainv1.TransferStatus_TRANSFER_SIGNING
case model.TransferStatusSubmitted:
return chainv1.TransferStatus_TRANSFER_SUBMITTED
case model.TransferStatusConfirmed:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case model.TransferStatusFailed:
return chainv1.TransferStatus_TRANSFER_FAILED
case model.TransferStatusCancelled:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name string
RPCURL string
ChainID uint64
NativeToken string
TokenConfigs []TokenContract
}
// TokenContract captures the metadata needed to work with a specific on-chain token.
type TokenContract struct {
Symbol string
ContractAddress string
}
// ServiceWallet captures the managed service wallet configuration.
type ServiceWallet struct {
Network string
Address string
PrivateKey string
}

View File

@@ -7,17 +7,19 @@ import (
"time"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
)
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network Network) {
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
if s.executor == nil {
return
}
go func(ref, walletRef string, net Network) {
go func(ref, walletRef string, net shared.Network) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
@@ -27,7 +29,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
}(transferRef, sourceWalletRef, network)
}
func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network Network) error {
func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network shared.Network) error {
transfer, err := s.storage.Transfers().Get(ctx, transferRef)
if err != nil {
return err

17
api/gateway/chain/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/gateway/chain/internal/appversion"
si "github.com/tech/sendico/gateway/chain/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("gateway", appversion.Create(), factory)
}

View File

@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/tech/sendico/chain/gateway/storage"
"github.com/tech/sendico/chain/gateway/storage/mongo/store"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/tech/sendico/chain/gateway/storage"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/tech/sendico/chain/gateway/storage"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"github.com/tech/sendico/chain/gateway/storage"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"

View File

@@ -3,7 +3,7 @@ package storage
import (
"context"
"github.com/tech/sendico/chain/gateway/storage/model"
"github.com/tech/sendico/gateway/chain/storage/model"
)
type storageError string

1
api/gateway/mntx/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/mntx-gateway

View File

@@ -0,0 +1,46 @@
# Monetix Gateway Card Payouts
This service now supports Monetix “payout by card”.
## Runtime entry points
- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`.
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
- Metrics: Prometheus on `:9404/metrics`.
## Required config/env
`api/gateway/mntx/config.yml` shows defaults. Key values (usually injected via env):
- `MONETIX_BASE_URL` e.g. `https://gate.monetix.com`
- `MONETIX_PROJECT_ID` integer project ID
- `MONETIX_SECRET_KEY` signature secret
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
## Outbound request (CreateCardPayout)
Payload is built per Monetix spec:
```
{
"general": { "project_id": <int>, "payment_id": "<payout_id>", "signature": "<hmac>" },
"customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? },
"payment": { amount: <minor_units>, currency: "<ISO-4217>" },
"card": { pan, year?, month?, card_holder }
}
```
Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`.
## Callback handling
- Endpoint only accepts POST with Monetix JSON body. Signature is verified with the same HMAC-SHA256 algorithm; invalid signatures return 403.
- Maps Monetix statuses:
- `payment.status=success` AND `operation.status=success` AND `operation.code` empty/`0``PAYOUT_STATUS_PROCESSED`
- `processing``PAYOUT_STATUS_PENDING`
- otherwise → `PAYOUT_STATUS_FAILED`
- Emits `CardPayoutStatusChangedEvent` over messaging (event type: `mntx_gateway`, action: `updated`).
## Metrics
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
- Existing RPC/payout counters remain for compatibility.
## Notes / PCI
- PAN is only logged in masked form; do not persist raw PAN.
- Callback allows CIDR allow-listing; leave empty to accept all while testing.

View File

@@ -0,0 +1,40 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50075"
enable_reflection: true
enable_health: true
metrics:
address: ":9404"
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Monetix Gateway Service
max_reconnects: 10
reconnect_wait: 5
monetix:
base_url_env: MONETIX_BASE_URL
project_id_env: MONETIX_PROJECT_ID
secret_key_env: MONETIX_SECRET_KEY
allowed_currencies: ["USD", "EUR"]
require_customer_address: false
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
http:
callback:
address: ":8084"
path: "/monetix/callback"
allowed_cidrs: []
max_body_bytes: 1048576

4
api/gateway/mntx/entrypoint.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
exec /app/mntx-gateway "$@"

54
api/gateway/mntx/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/tech/sendico/gateway/mntx
go 1.25.3
replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

227
api/gateway/mntx/go.sum Normal file
View File

@@ -0,0 +1,227 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Some files were not shown because too many files have changed in this diff Show More