83 Commits

Author SHA1 Message Date
2d735aa7f5 Merge pull request 'hex parser + test' (#141) from tron-148 into main
Some checks failed
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
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/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #141
2025-12-24 02:53:38 +00:00
Stephan D
342dd5328f hex parser + test 2025-12-24 03:53:20 +01:00
915ed66b08 Merge pull request 'fixed big int reader' (#140) from tron-146 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/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
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #140
2025-12-24 02:15:36 +00:00
Stephan D
fe73b3078a fixed big int reader 2025-12-24 03:15:12 +01:00
76204822e7 Merge pull request 'fixed proto message' (#139) from tron-142 into main
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
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/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #139
2025-12-24 01:57:36 +00:00
Stephan D
77c205f9b2 fixed proto message 2025-12-24 02:57:15 +01:00
6a29dc8907 Merge pull request 'compilation fix' (#138) from tron-144 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/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 failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #138
2025-12-24 01:17:24 +00:00
Stephan D
8f1f279792 compilation fix 2025-12-24 02:17:01 +01:00
1f0b54d590 Merge pull request 'extended logging + timeout setting' (#137) from tron-140 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline 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/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
Reviewed-on: #137
2025-12-24 01:00:20 +00:00
Stephan D
cefb9706f9 extended logging + timeout setting 2025-12-24 01:59:37 +01:00
79b7899658 Merge pull request 'refactored initialization' (#136) from tron-138 into main
Some checks 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
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #136
2025-12-24 00:32:09 +00:00
Stephan D
c941319c4e refactored initialization 2025-12-24 01:31:43 +01:00
e6626600cc Merge pull request 'extra logging' (#135) from tron-136 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway 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/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway 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: #135
2025-12-23 17:36:51 +00:00
Stephan D
e74c06e87a extra logging 2025-12-23 18:36:22 +01:00
c3647bfc46 Merge pull request 'version bump' (#134) from tron-136 into main
Some checks failed
ci/woodpecker/push/db Pipeline was successful
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/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #134
2025-12-23 16:43:46 +00:00
Stephan D
3ff81038a9 version bump 2025-12-23 17:43:20 +01:00
d6d9d47e67 Merge pull request 'Added new tron networks' (#133) from tron-134 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
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/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 failed
Reviewed-on: #133
2025-12-23 16:23:50 +00:00
Stephan D
034eb943e2 Added new tron networks 2025-12-23 17:21:58 +01:00
93bd0bf002 Merge pull request 'fixed config + improved logging' (#132) from tron-132 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff 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 failed
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #132
2025-12-23 15:28:27 +00:00
Stephan D
946bfa217c fixed config + improved logging 2025-12-23 16:26:06 +01:00
318255405b Merge pull request 'Migration to TRON chain' (#130) from tron-130 into main
Some checks are 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 is running
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
Reviewed-on: #130
2025-12-23 14:42:30 +00:00
Stephan D
19d4ee1d33 Migration to TRON chain 2025-12-23 15:42:07 +01:00
bc6a56c129 Merge pull request 'Implemented cooldown before User is able to resend confirmation code for 2fa' (#128) from SEND012 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/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/chain_gateway 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: #128
2025-12-23 12:56:07 +00:00
Arseni
ec54579921 Implemented cooldown before User is able to resend confirmation code for 2fa 2025-12-23 14:56:47 +03:00
1ed76f7243 Merge pull request 'Fixed tokens revocation' (#127) from devid-122 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff 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 is running
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #127
2025-12-22 20:25:03 +00:00
Stephan D
6527d183ec Fixed tokens revokation 2025-12-22 21:22:51 +01:00
41b0dec460 Merge pull request 'Fixes for Settings Page' (#123) from SEND011 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/mntx_gateway 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: #123
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-22 19:26:44 +00:00
Arseni
d26ba84094 Moved the AccountName widgets to pull providers from context 2025-12-22 21:38:26 +03:00
Arseni
4073c8819c Fixed imports 2025-12-22 21:12:21 +03:00
Arseni
47ada0691c Fixes for Settings Page 2025-12-22 21:09:58 +03:00
97c67670e5 Merge pull request 'added fix for active indexed tokens + improved data structure for wallet description' (#122) from quotes-118 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/mntx_gateway 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: #122
2025-12-22 17:30:48 +00:00
Stephan D
dfad7fb335 added fix for active indexed tokens + improved data structure for wallet description 2025-12-22 18:30:15 +01:00
41abf723e6 Merge pull request 'multiquote service' (#117) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff 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/mntx_gateway 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: #117
2025-12-17 20:56:28 +00:00
Stephan D
2d6586430f multiquote service 2025-12-17 21:56:07 +01:00
d649748f6f Merge pull request 'server endpoint' (#115) from quotes-115 into main
All checks were successful
ci/woodpecker/push/bff 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/billing_fees 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/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #115
2025-12-17 17:15:40 +00:00
Stephan D
61177a4e30 server endpoint 2025-12-17 18:15:02 +01:00
c7b9b70d57 Merge pull request 'multiple quotes payment' (#114) from quotes-114 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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #114
2025-12-17 15:53:29 +00:00
Stephan D
5030453807 multiple quotes payment 2025-12-17 16:53:03 +01:00
5565081b69 Merge pull request 'PostHog last fixes hopefully' (#109) from SEND006 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/nats 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/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #109
2025-12-17 12:22:33 +00:00
b216aa68b7 Merge pull request 'Empty state for Recipient Address Book' (#110) from SEND007 into main
Reviewed-on: #110
2025-12-17 11:48:07 +00:00
7ac1c519e3 Merge pull request 'Made clear massages for errors in recipient registration' (#111) from SEND008 into main
Some checks are 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/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #111
2025-12-17 11:13:16 +00:00
076b0c6434 Merge pull request 'Wallet update for correct name and symbol appearance' (#112) from SEND009 into main
Some checks failed
ci/woodpecker/push/bff 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/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 failed
Reviewed-on: #112
2025-12-17 11:12:26 +00:00
Arseni
9a90e6a03b Wallet update for correct name and symbol appearance 2025-12-16 19:37:28 +03:00
Arseni
5218632c00 Made clear massages for errors in recipient registration 2025-12-16 18:42:57 +03:00
Arseni
a2c05745ad A 2025-12-16 18:21:49 +03:00
Arseni
82b2f88122 Changed the spelling of the word adress) 2025-12-12 19:39:36 +03:00
Arseni
28d74d058b Empty state for Recipient Adress Book 2025-12-12 19:29:10 +03:00
Arseni
6ee146b95a PostHog last fixes hopefully 2025-12-12 16:39:18 +03:00
67b52af150 Merge pull request 'fixed dropping of settlement mode' (#108) from settlement-106 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/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 failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #108
2025-12-12 13:19:29 +00:00
Stephan D
058a3fefaf fixed dropping of settlement mode 2025-12-12 14:19:09 +01:00
c8a97d940c Merge pull request 'quotation rate display' (#105) from fees-104 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/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
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/mntx_gateway Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
Reviewed-on: #105
2025-12-12 12:46:21 +00:00
Stephan D
00045c1e65 quotation rate display 2025-12-12 13:45:58 +01:00
d64d7dab58 Merge pull request 'fixed quotation calculation logic' (#103) from fees-102 into main
Some checks 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/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 failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #103
2025-12-12 12:42:13 +00:00
Stephan D
4746a00eee fixed quotation calculation logic 2025-12-12 13:41:37 +01:00
3f8399d647 Merge pull request 'fixed fee polarity' (#101) from currency-100 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/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 failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #101
2025-12-12 12:21:19 +00:00
Stephan D
028b29fe08 fixed fee polarity 2025-12-12 13:21:00 +01:00
cb3f59a9d5 Merge pull request 'fixed duplicated ISO codes parsing' (#99) from currency-99 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: #99
2025-12-12 10:42:07 +00:00
Stephan D
2b8d02d95c fixed duplicated ISO codes parsing 2025-12-12 11:41:45 +01:00
d90d8cda11 Merge pull request 'fx/ingestor currencies map fixed' (#98) from currencies-97 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 was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
Reviewed-on: #98
2025-12-12 10:05:52 +00:00
Stephan D
17333df7af fx/ingestor currencies map fixed 2025-12-12 11:05:12 +01:00
681d53e856 Merge pull request 'Fixed http client' (#96) from client-95 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: #96
2025-12-12 00:53:59 +00:00
Stephan D
dc608fd257 Fixed http client 2025-12-12 01:53:34 +01:00
cd2efdc2f3 Merge pull request 'improved fx/ingestor logging' (#93) from logging-92 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/payments_orchestrator 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/notification Pipeline was successful
Reviewed-on: #93
2025-12-12 00:07:25 +00:00
Stephan D
fd47867101 improved fx/ingestor logging 2025-12-12 01:07:03 +01:00
2ca1a6956c Merge pull request 'fx/oracle logging' (#92) from logging-90 into main
Some checks 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/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/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
Reviewed-on: #92
2025-12-12 00:05:13 +00:00
Stephan D
a5ad4f4c3c fx/oracle logging 2025-12-12 00:44:04 +01:00
8b202e0c60 Merge pull request 'fixed currency validation logic' (#89) from currency-88 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: #89
2025-12-11 22:55:50 +00:00
Stephan D
4626d0a1a7 fixed currency validation logic 2025-12-11 23:55:04 +01:00
da72121109 Merge pull request 'ledger account reference removed' (#87) from fees-86 into main
Some checks 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/chain_gateway 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: #87
2025-12-11 22:32:26 +00:00
Stephan D
5bebadf17c ledger account reference removed 2025-12-11 23:30:42 +01:00
1bab0b14ef Merge pull request 'fixed currency pair validation' (#85) from currency-84 into main
Some checks 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/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 failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #85
2025-12-11 22:27:42 +00:00
Stephan D
39f323d050 fixed currency pair validation 2025-12-11 23:27:15 +01:00
7cd9e14618 Merge pull request 'logging-84' (#83) from logging-84 into main
All checks were successful
ci/woodpecker/push/db 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/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/mntx_gateway 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: #83
2025-12-11 21:37:04 +00:00
Stephan D
b77d2c16ab improved logging 2025-12-11 22:25:51 +01:00
Stephan D
324f150950 improved logging 2025-12-11 22:25:04 +01:00
dd6bcf843c Merge pull request 'fixed payment orchestrator address' (#81) from connectivity-81 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #81
2025-12-11 20:53:04 +00:00
Stephan D
874cc4971b fixed payment orchestrator address 2025-12-11 21:52:37 +01:00
bfe4695b2d Merge pull request 'config fix' (#80) from discovery-79 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/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 failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #80
2025-12-11 20:39:07 +00:00
Stephan D
99161c8e7d config fix 2025-12-11 21:38:32 +01:00
6901791dd2 Merge pull request 'default currency resolver' (#78) from currency-76 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/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 failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #78
2025-12-11 20:24:10 +00:00
Stephan D
acb3d14b47 default currency resolver 2025-12-11 21:23:35 +01:00
aa5f7e271e Merge pull request 'fix currencies validation' (#76) from currencies-75 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/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 failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #76
2025-12-11 20:06:22 +00:00
Stephan D
0a01995f53 fix currencies validation 2025-12-11 21:05:43 +01:00
221 changed files with 5356 additions and 1412 deletions

3
.gitignore vendored
View File

@@ -9,4 +9,5 @@ untranslated.txt
generate_protos.sh
update_dep.sh
.vscode/
.gocache/
.gocache/
.cache/

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -31,7 +31,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2
@@ -49,6 +49,6 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/protobuf v1.36.11
)

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -90,10 +90,6 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
}
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil {
@@ -113,7 +109,8 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
}
meta := map[string]string{

View File

@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
}
}
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
}
// Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
if orgRef != nil && !orgRef.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
return nil, nil, selErr
}
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
return nil, nil, err
}
}

View File

@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if !plan.GetOrganizationRef().IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
}
if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
@@ -59,8 +59,7 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
orgPlan.SetOrganizationRef(org)
orgPlan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
resolver := New(store, zap.NewNop())
@@ -95,7 +94,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
plan.SetOrganizationRef(org)
plan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
@@ -136,7 +135,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
},
}
orgPlan.SetOrganizationRef(org)
orgPlan.OrganizationRef = &org
globalPlan := &model.FeePlan{
Active: true,
@@ -221,7 +220,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
p1.SetOrganizationRef(org)
p1.OrganizationRef = &org
p2 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-30 * time.Minute),
@@ -229,7 +228,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
p2.SetOrganizationRef(org)
p2.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
resolver := New(store, zap.NewNop())
@@ -263,7 +262,7 @@ func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.O
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || plan.GetOrganizationRef() != orgRef {
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
continue
}
if !plan.Active {
@@ -289,7 +288,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || !plan.GetOrganizationRef().IsZero() {
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
continue
}
if !plan.Active {

View File

@@ -0,0 +1,88 @@
package fees
import (
"strings"
"time"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
fields := logFieldsFromRequestMeta(meta)
fields = append(fields, logFieldsFromIntent(intent)...)
return fields
}
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
if meta == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
return fields
}
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
if intent == nil {
return nil
}
fields := make([]zap.Field, 0, 5)
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
fields = append(fields, zap.String("trigger", trigger.String()))
}
if base := intent.GetBaseAmount(); base != nil {
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
fields = append(fields, zap.String("base_amount", amount))
}
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
fields = append(fields, zap.String("base_currency", currency))
}
}
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
}
if attrs := intent.GetAttributes(); len(attrs) > 0 {
fields = append(fields, zap.Int("attributes_count", len(attrs)))
}
return fields
}
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
fields = append(fields, zap.String("request_ref", reqRef))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
fields = append(fields, zap.String("trace_ref", traceRef))
}
return fields
}
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
if payload == nil {
return nil
}
fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
if payload.ExpiresAtUnixMs > 0 {
fields = append(fields,
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
}
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
return fields
}

View File

@@ -72,26 +72,57 @@ func (s *Service) Register(router routers.GRPC) error {
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
intent *feesv1.Intent
)
if req != nil {
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
if intent != nil {
trigger = intent.GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
}
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if err != nil {
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("QuoteFees finished", logFields...)
}()
logger.Debug("QuoteFees request received")
if err = s.validateQuoteRequest(req); err != nil {
return nil, err
}
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
@@ -112,20 +143,59 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
}
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
intent *feesv1.Intent
)
if req != nil {
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
if intent != nil {
trigger = intent.GetTrigger()
}
var fxUsed bool
var (
fxUsed bool
expiresAt time.Time
)
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
if ts := resp.GetExpiresAt(); ts != nil {
expiresAt = ts.AsTime()
}
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if !expiresAt.IsZero() {
logFields = append(logFields, zap.Time("expires_at", expiresAt))
}
if err != nil {
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("PrecomputeFees finished", logFields...)
}()
logger.Debug("PrecomputeFees request received")
if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err
}
@@ -134,6 +204,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
@@ -148,7 +219,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 {
ttl = 60000
}
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
@@ -159,7 +230,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
var token string
if token, err = encodeTokenPayload(payload); err != nil {
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
@@ -176,9 +247,18 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
}
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
tokenLen := 0
if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
}
logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var fxUsed bool
var (
fxUsed bool
resultReason string
)
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
@@ -191,9 +271,28 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
}
}
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Bool("valid", resp != nil && resp.GetValid()),
}
if resultReason != "" {
logFields = append(logFields, zap.String("reason", resultReason))
}
if err != nil {
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("ValidateFeeToken finished", logFields...)
}()
logger.Debug("ValidateFeeToken request received")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
@@ -202,21 +301,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resultReason = "invalid_token"
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
trigger = payload.Intent.GetTrigger()
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
if payload.Intent != nil {
trigger = payload.Intent.GetTrigger()
}
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resultReason = "invalid_token"
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
@@ -280,6 +387,16 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime()
}
logFields := []zap.Field{
zap.Time("booked_at_used", bookedAt),
}
if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
}
logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...)
var orgPtr *primitive.ObjectID
if !orgRef.IsZero() {
orgPtr = &orgRef
@@ -288,7 +405,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
@@ -297,7 +414,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default:
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
}
}
@@ -313,7 +430,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}

View File

@@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
service := NewService(
zap.NewNop(),
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
service := NewService(
zap.NewNop(),
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
service := NewService(
zap.NewNop(),
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
@@ -452,7 +452,7 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.O
if s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.GetOrganizationRef() != orgRef {
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.Active {

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
@@ -25,14 +26,14 @@ const (
// FeePlan describes a collection of fee rules for an organisation.
type FeePlan struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
storable.Base `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.

View File

@@ -11,12 +11,18 @@ market:
- driver: CBR
settings:
base_url: "https://www.cbr.ru"
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
pairs:
BINANCE:
- base: "USDT"
quote: "EUR"
symbol: "EURUSDT"
invert: true
- base: "USD"
quote: "USDT"
symbol: "USDTUSD"
invert: true
- base: "UAH"
quote: "USDT"
symbol: "USDTUAH"

View File

@@ -32,7 +32,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -49,7 +49,7 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -85,6 +85,7 @@ func (s *Service) executePoll(ctx context.Context) error {
func (s *Service) pollOnce(ctx context.Context) error {
var firstErr error
failures := 0
for _, pair := range s.pairs {
start := time.Now()
err := s.upsertPair(ctx, pair)
@@ -96,14 +97,24 @@ func (s *Service) pollOnce(ctx context.Context) error {
if firstErr == nil {
firstErr = err
}
failures++
s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()),
zap.String("provider", pair.Provider),
zap.String("base", pair.Base),
zap.String("quote", pair.Quote),
zap.Bool("invert", pair.Invert),
zap.Duration("elapsed", elapsed),
zap.Error(err),
)
}
}
if failures > 0 {
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
} else {
s.logger.Info("Ingestion poll completed", zap.Int("total", len(s.pairs)))
}
return firstErr
}
@@ -115,7 +126,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil {
return merrors.InternalWrap(err, "fetch ticker")
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
}
bid, err := parseDecimal(ticker.BidPrice)
@@ -172,9 +183,14 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
s.logger.Debug("Snapshot ingested",
zap.String("pair", pair.Base+"/"+pair.Quote),
zap.String("provider", pair.Provider),
zap.String("source", pair.Source.String()),
zap.String("provider_ref", snapshot.ProviderRef),
zap.String("bid", snapshot.Bid),
zap.String("ask", snapshot.Ask),
zap.String("mid", snapshot.Mid),
zap.String("spread_bps", snapshot.SpreadBps),
zap.Int64("asof_unix_ms", snapshot.AsOfUnixMs),
zap.String("rate_ref", snapshot.RateRef),
)
return nil

View File

@@ -23,7 +23,7 @@ import (
type cbrConnector struct {
id mmodel.Driver
provider string
client *http.Client
http *httpClient
base string
dailyPath string
directoryPath string
@@ -60,6 +60,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath
userAgent := defaultUserAgent
acceptHeader := defaultAccept
if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
@@ -77,6 +79,12 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value)
}
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
userAgent = strings.TrimSpace(value)
}
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
acceptHeader = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
@@ -99,13 +107,24 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
transport = customTransport
}
client := &http.Client{
Timeout: requestTimeout,
Transport: transport,
}
referer := parsed.String()
connector := &cbrConnector{
id: mmodel.DriverCBR,
provider: provider,
client: &http.Client{
Timeout: requestTimeout,
Transport: transport,
},
http: newHTTPClient(
logger,
client,
httpClientOptions{
userAgent: userAgent,
accept: acceptHeader,
referer: referer,
},
),
base: strings.TrimRight(parsed.String(), "/"),
dailyPath: dailyPath,
directoryPath: directoryPath,
@@ -161,20 +180,32 @@ func (c *cbrConnector) refreshDirectory() error {
return err
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
if err != nil {
return merrors.InternalWrap(err, "cbr: build directory request")
}
resp, err := c.client.Do(req)
resp, err := c.http.Do(req)
if err != nil {
c.logger.Warn("CBR directory request failed", zap.Error(err))
c.logger.Warn(
"CBR directory request failed",
zap.Error(err),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return merrors.InternalWrap(err, "cbr: directory request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
c.logger.Warn(
"CBR directory returned non-OK status",
zap.Int("status", resp.StatusCode),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
@@ -183,12 +214,13 @@ func (c *cbrConnector) refreshDirectory() error {
var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err))
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
return merrors.InternalWrap(err, "cbr: decode directory")
}
mapping, err := buildValuteMapping(directory.Items)
mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
if err != nil {
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
return err
}
@@ -200,23 +232,32 @@ func (c *cbrConnector) refreshDirectory() error {
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
endpoint, err := c.buildURL(c.dailyPath, nil)
if err != nil {
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
if err != nil {
c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
return "", merrors.InternalWrap(err, "cbr: build daily request")
}
resp, err := c.client.Do(req)
resp, err := c.http.Do(req)
if err != nil {
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
c.logger.Warn("CBR daily request failed",
zap.Error(err), zap.String("currency", valute.ISOCharCode),
zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return "", merrors.InternalWrap(err, "cbr: daily request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
c.logger.Warn(
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
@@ -225,7 +266,9 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
var payload dailyRates
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err))
c.logger.Warn("CBR daily decode failed", zap.Error(err),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.InternalWrap(err, "cbr: decode daily response")
}
@@ -247,25 +290,40 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
"date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID,
}
dateStr := date.Format("2006-01-02")
endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: build historical request")
}
resp, err := c.client.Do(req)
resp, err := c.http.Do(req)
if err != nil {
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
c.logger.Warn(
"CBR historical request failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: historical request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
c.logger.Warn(
"CBR historical returned non-OK status",
zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
@@ -274,7 +332,13 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
var payload dynamicRates
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR historical decode failed", zap.Error(err))
c.logger.Warn(
"CBR historical decode failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: decode historical response")
}
@@ -337,7 +401,7 @@ type valuteMapping struct {
byID map[string]valuteInfo
}
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items))
@@ -348,12 +412,19 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name)
engName := strings.TrimSpace(item.EngName)
nominal, err := parseNominal(item.NominalStr)
if err != nil {
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
}
if id == "" || isoChar == "" {
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
logger.Info("Skipping invalid currency entry",
zap.String("id", id),
zap.String("iso_char", isoChar),
zap.String("name", name),
)
continue
}
info := valuteInfo{
@@ -365,12 +436,76 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
Nominal: nominal,
}
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
if existing, ok := byISO[isoChar]; ok {
// Same ISO + same ID: duplicate entry, just ignore.
if existing.ID == id {
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
zap.String("iso_code", isoChar),
zap.String("id", id),
)
continue
}
// Different IDs but same ISO char.
// Choose canonical entry:
// 1) Prefer nominal == 1
// 2) Otherwise prefer smaller nominal
keepExisting := true
if existing.Nominal != 1 && info.Nominal == 1 {
keepExisting = false
} else if existing.Nominal == 1 && info.Nominal != 1 {
keepExisting = true
} else if info.Nominal < existing.Nominal {
keepExisting = false
}
if keepExisting {
logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
zap.String("iso_code", isoChar),
zap.String("existing_id", existing.ID),
zap.Int64("existing_nominal", existing.Nominal),
zap.String("new_id", info.ID),
zap.Int64("new_nominal", info.Nominal),
)
// We keep the old one, just skip the new.
continue
}
// Replace existing mapping with the new, more canonical one.
logger.Warn("Replacing currency mapping due to more canonical nominal",
zap.String("iso_code", isoChar),
zap.String("old_id", existing.ID),
zap.Int64("old_nominal", existing.Nominal),
zap.String("new_id", info.ID),
zap.Int64("new_nominal", info.Nominal),
)
// Update byID: drop old ID, add new one
delete(byID, existing.ID)
byID[id] = info
// Update ISO mapping
byISO[isoChar] = info
// Update numeric-code index if present
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
}
byNum[isoNum] = id
}
continue
}
// No existing ISO entry, do normal uniqueness checks.
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
}
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
@@ -378,6 +513,8 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
byNum[isoNum] = id
}
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
byISO[isoChar] = info
byID[id] = info
}

View File

@@ -0,0 +1,85 @@
package cbr
import (
"context"
"net/http"
"strings"
"go.uber.org/zap"
)
const (
defaultUserAgent = "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
defaultAccept = "application/xml,text/xml;q=0.9,*/*;q=0.8"
)
// httpClient wraps http.Client to ensure CBR requests always carry required headers.
type httpClient struct {
client *http.Client
headers http.Header
logger *zap.Logger
}
type httpClientOptions struct {
userAgent string
accept string
referer string
}
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
userAgent := opts.userAgent
if strings.TrimSpace(userAgent) == "" {
userAgent = defaultUserAgent
}
accept := opts.accept
if strings.TrimSpace(accept) == "" {
accept = defaultAccept
}
referer := opts.referer
if strings.TrimSpace(referer) == "" {
referer = defaultCBRBaseURL
}
l := logger.Named("http_client")
headers := make(http.Header, 3)
headers.Set("User-Agent", userAgent)
headers.Set("Accept", accept)
headers.Set("Referer", referer)
l.Info("HTTP client initialized", zap.String("user_agent", userAgent),
zap.String("accept", accept), zap.String("referrer", referer))
return &httpClient{
client: client,
headers: headers,
logger: l,
}
}
func (h *httpClient) NewRequest(ctx context.Context, method, endpoint string) (*http.Request, error) {
return http.NewRequestWithContext(ctx, method, endpoint, nil)
}
func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
enriched := req.Clone(req.Context())
for key, values := range h.headers {
if enriched.Header.Get(key) != "" {
continue
}
for _, value := range values {
enriched.Header.Add(key, value)
}
}
r, err := h.client.Do(enriched)
if err != nil {
h.logger.Warn("HTTP request failed", zap.Error(err), zap.String("method", req.Method),
zap.String("url", req.URL.String()))
}
return r, err
}
func (h *httpClient) headerValue(name string) string {
return h.headers.Get(name)
}

View File

@@ -40,15 +40,15 @@ func main() {
application, err := app.New(logger, *configFile)
if err != nil {
logger.Fatal("Failed to initialise application", zap.Error(err))
}
if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) {
logger.Info("FX ingestor stopped")
return
logger.Error("Failed to initialise application", zap.Error(err))
} else {
if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) {
logger.Info("FX ingestor stopped")
return
}
logger.Error("Ingestor terminated with error", zap.Error(err))
}
logger.Fatal("Ingestor terminated with error", zap.Error(err))
}
logger.Info("FX ingestor stopped")

View File

@@ -13,8 +13,8 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,7 +33,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -0,0 +1,90 @@
package oracle
import (
"strings"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.uber.org/zap"
)
func quoteRequestFields(req *oraclev1.GetQuoteRequest) []zap.Field {
if req == nil {
return nil
}
fields := requestMetaFields(req.GetMeta())
if pair := req.GetPair(); pair != nil {
if base := strings.TrimSpace(pair.GetBase()); base != "" {
fields = append(fields, zap.String("pair_base", base))
}
if quote := strings.TrimSpace(pair.GetQuote()); quote != "" {
fields = append(fields, zap.String("pair_quote", quote))
}
}
if side := req.GetSide(); side != fxv1.Side_SIDE_UNSPECIFIED {
fields = append(fields, zap.String("side", side.String()))
}
if req.GetFirm() {
fields = append(fields, zap.Bool("firm", req.GetFirm()))
}
if ttl := req.GetTtlMs(); ttl > 0 {
fields = append(fields, zap.Int64("ttl_ms", ttl))
}
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
fields = append(fields, zap.Int32("max_age_ms", maxAge))
}
if provider := strings.TrimSpace(req.GetPreferredProvider()); provider != "" {
fields = append(fields, zap.String("preferred_provider", provider))
}
fields = append(fields, moneyFields("base_amount", req.GetBaseAmount())...)
fields = append(fields, moneyFields("quote_amount", req.GetQuoteAmount())...)
return fields
}
func requestMetaFields(meta *oraclev1.RequestMeta) []zap.Field {
if meta == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
if tenant := strings.TrimSpace(meta.GetTenantRef()); tenant != "" {
fields = append(fields, zap.String("tenant_ref", tenant))
}
fields = append(fields, traceFields(meta.GetTrace())...)
return fields
}
func moneyFields(prefix string, money *moneyv1.Money) []zap.Field {
if money == nil {
return nil
}
fields := make([]zap.Field, 0, 2)
if amt := strings.TrimSpace(money.GetAmount()); amt != "" {
fields = append(fields, zap.String(prefix, amt))
}
if ccy := strings.TrimSpace(money.GetCurrency()); ccy != "" {
fields = append(fields, zap.String(prefix+"_currency", ccy))
}
return fields
}
func traceFields(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if ref := strings.TrimSpace(trace.GetTraceRef()); ref != "" {
fields = append(fields, zap.String("trace_ref", ref))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if req := strings.TrimSpace(trace.GetRequestRef()); req != "" {
fields = append(fields, zap.String("request_ref", req))
}
return fields
}

View File

@@ -101,22 +101,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if req == nil {
req = &oraclev1.GetQuoteRequest{}
}
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
logger := s.logger.With(quoteRequestFields(req)...)
logger.Debug("Handling GetQuote")
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
logger.Warn("GetQuote invalid: side missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
}
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
logger.Warn("GetQuote invalid: both base_amount and quote_amount provided")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
}
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
logger.Warn("GetQuote invalid: amount missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
logger.Warn("GetQuote invalid: pair missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
}
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -125,8 +130,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default:
logger.Warn("GetQuote failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
@@ -143,8 +150,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default:
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
@@ -153,27 +162,31 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
age := now.UnixMilli() - rate.AsOfUnixMs
if age > int64(maxAge) {
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
}
}
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
if err != nil {
logger.Warn("GetQuote invalid input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
if req.GetBaseAmount() != nil {
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
logger.Warn("GetQuote invalid base input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
} else if req.GetQuoteAmount() != nil {
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
logger.Warn("GetQuote invalid quote input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
if err := comp.compute(); err != nil {
logger.Warn("GetQuote computation failed", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
@@ -195,12 +208,14 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
switch {
case errors.Is(err, merrors.ErrDataConflict):
logger.Warn("GetQuote conflict issuing firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
default:
logger.Warn("GetQuote failed to issue firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
}
resp := &oraclev1.GetQuoteResponse{
@@ -214,18 +229,24 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
if req == nil {
req = &oraclev1.ValidateQuoteRequest{}
}
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
logger = logger.With(zap.String("quote_ref", ref))
}
logger.Debug("Handling ValidateQuote")
if req.GetQuoteRef() == "" {
logger.Warn("ValidateQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
}
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: nil,
@@ -234,6 +255,7 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
}
return gsresponse.Success(resp)
default:
logger.Warn("ValidateQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
@@ -255,6 +277,11 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
Valid: valid,
Reason: reason,
}
if !valid {
logger.Info("ValidateQuote invalid", zap.String("reason", reason), zap.Bool("firm", quote.Firm))
} else {
logger.Debug("ValidateQuote valid", zap.Bool("firm", quote.Firm))
}
return gsresponse.Success(resp)
}
@@ -262,29 +289,43 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
if req == nil {
req = &oraclev1.ConsumeQuoteRequest{}
}
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
logger = logger.With(zap.String("quote_ref", ref))
}
if ledger := strings.TrimSpace(req.GetLedgerTxnRef()); ledger != "" {
logger = logger.With(zap.String("ledger_txn_ref", ledger))
}
logger.Debug("Handling ConsumeQuote")
if req.GetQuoteRef() == "" {
logger.Warn("ConsumeQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
}
if req.GetLedgerTxnRef() == "" {
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
}
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
if err != nil {
switch {
case errors.Is(err, storage.ErrQuoteExpired):
logger.Warn("ConsumeQuote failed: expired")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
case errors.Is(err, storage.ErrQuoteConsumed):
logger.Warn("ConsumeQuote failed: already consumed")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
case errors.Is(err, storage.ErrQuoteNotFirm):
logger.Warn("ConsumeQuote failed: quote not firm")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
case errors.Is(err, merrors.ErrNoData):
logger.Warn("ConsumeQuote failed: quote not found")
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
default:
logger.Warn("ConsumeQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
@@ -294,7 +335,7 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
Consumed: true,
Reason: "consumed",
}
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
logger.Info("Quote consumed")
return gsresponse.Success(resp)
}
@@ -302,13 +343,21 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if req == nil {
req = &oraclev1.LatestRateRequest{}
}
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
if pair := req.GetPair(); pair != nil {
logger = logger.With(zap.String("pair_base", strings.TrimSpace(pair.GetBase())), zap.String("pair_quote", strings.TrimSpace(pair.GetQuote())))
}
if provider := strings.TrimSpace(req.GetProvider()); provider != "" {
logger = logger.With(zap.String("provider", provider))
}
logger.Debug("Handling LatestRate")
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
logger.Warn("LatestRate invalid: pair missing")
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
}
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -317,8 +366,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate pair not found")
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default:
logger.Warn("LatestRate failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
}
@@ -335,8 +386,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate not found", zap.String("provider", provider))
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default:
logger.Warn("LatestRate failed", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
}
@@ -345,6 +398,7 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
Meta: buildResponseMeta(req.GetMeta()),
Rate: rateModelToProto(rate),
}
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
return gsresponse.Success(resp)
}
@@ -352,13 +406,15 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
if req == nil {
req = &oraclev1.ListPairsRequest{}
}
s.logger.Debug("Handling ListPairs")
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
logger.Debug("Handling ListPairs")
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
}
pairs, err := s.storage.Pairs().ListEnabled(ctx)
if err != nil {
logger.Warn("ListPairs failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
}
result := make([]*oraclev1.PairMeta, 0, len(pairs))
@@ -369,7 +425,7 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
Meta: buildResponseMeta(req.GetMeta()),
Pairs: result,
}
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
return gsresponse.Success(resp)
}

View File

@@ -28,5 +28,5 @@ require (
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -169,7 +169,7 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -34,16 +34,18 @@ messaging:
reconnect_wait: 5
chains:
- name: arbitrum_one
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
- name: tron_mainnet
chain_id: 728126428 # 0x2b6653dc
native_token: TRX
rpc_url_env: CHAIN_GATEWAY_RPC_URL
tokens:
- symbol: USDC
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
- symbol: USDT
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
- symbol: USDC
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
service_wallet:
chain: arbitrum_one
chain: tron_mainnet
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
@@ -58,3 +60,4 @@ key_management:
cache:
wallet_balance_ttl_seconds: 120
rpc_request_timeout_seconds: 15

View File

@@ -15,14 +15,14 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
@@ -60,7 +60,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -86,5 +86,5 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

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-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 h1:wCr/SrKzMrtW9wG85ApPfncRr7ajzkRevhsWnCkl2sE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -207,8 +207,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -362,12 +362,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -2,6 +2,7 @@ package serverimp
import (
"context"
"fmt"
"os"
"strings"
"time"
@@ -10,6 +11,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
@@ -30,6 +32,8 @@ type Imp struct {
config *config
app *grpcapp.App[storage.Repository]
rpcClients *rpcclient.Clients
}
type config struct {
@@ -84,6 +88,9 @@ func (i *Imp) Shutdown() {
defer cancel()
i.app.Shutdown(ctx)
if i.rpcClients != nil {
i.rpcClients.Close()
}
}
func (i *Imp) Start() error {
@@ -98,7 +105,17 @@ func (i *Imp) Start() error {
}
cl := i.logger.Named("config")
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
if err != nil {
i.logger.Error("invalid chain network configuration", zap.Error(err))
return err
}
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
if err != nil {
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
return err
}
i.rpcClients = rpcClients
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
if err != nil {
@@ -106,12 +123,13 @@ func (i *Imp) Start() error {
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
executor := gatewayservice.NewOnChainExecutor(logger, keyManager, rpcClients)
opts := []gatewayservice.Option{
gatewayservice.WithNetworks(networkConfigs),
gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithTransferExecutor(executor),
gatewayservice.WithRPCClients(rpcClients),
gatewayservice.WithSettings(cfg.Settings),
}
return gatewayservice.NewService(logger, repo, producer, opts...), nil
@@ -157,7 +175,7 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil
}
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" {
@@ -166,7 +184,8 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
}
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" {
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
}
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
for _, token := range chain.Tokens {
@@ -202,7 +221,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
TokenConfigs: contracts,
})
}
return result
return result, nil
}
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {

View File

@@ -2,7 +2,9 @@ package transfer
import (
"context"
"time"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
@@ -11,9 +13,10 @@ import (
type Deps struct {
Logger mlogger.Logger
Networks map[string]shared.Network
Networks *rpcclient.Registry
Storage storage.Repository
Clock clockpkg.Clock
RPCTimeout time.Duration
EnsureRepository func(context.Context) error
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
}

View File

@@ -10,8 +10,9 @@ import (
"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/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
@@ -63,7 +64,7 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks[networkKey]
networkCfg, ok := c.deps.Networks.Network(networkKey)
if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
@@ -85,7 +86,7 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
}
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, c.deps.Networks, networkCfg, c.deps.RPCTimeout, 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)
@@ -98,11 +99,14 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
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) {
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, registry *rpcclient.Registry, network shared.Network, timeout time.Duration, 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 registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if strings.TrimSpace(wallet.ContractAddress) == "" {
return nil, merrors.NotImplemented("native token transfers not supported")
}
@@ -116,13 +120,19 @@ func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shar
return nil, merrors.InvalidArgument("invalid destination address")
}
client, err := ethclient.DialContext(ctx, rpcURL)
client, err := registry.Client(network.Name)
if err != nil {
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
return nil, err
}
rpcClient, err := registry.RPCClient(network.Name)
if err != nil {
return nil, err
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if timeout <= 0 {
timeout = 15 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
@@ -133,7 +143,7 @@ func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shar
toAddr := common.HexToAddress(destination)
fromAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
if err != nil {
logger.Warn("failed to read token decimals", zap.Error(err))
return nil, err
@@ -179,31 +189,20 @@ func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shar
}, 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())
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(token.Hex()),
"data": "0x313ce567",
}
msg := ethereum.CallMsg{
To: &token,
Data: callData,
}
output, err := client.CallContract(ctx, msg, nil)
if err != nil {
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", output)
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
return 0, merrors.Internal("decimals decode failed: " + 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
return val, nil
}
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {

View File

@@ -78,7 +78,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
}
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks[networkKey]
networkCfg, ok := c.deps.Networks.Network(networkKey)
if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
@@ -59,7 +60,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
networkCfg, ok := c.deps.Networks[chainKey]
networkCfg, ok := c.deps.Networks.Network(chainKey)
if !ok {
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
@@ -95,7 +96,31 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
}
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey,
WalletRef: walletRef,
OrganizationRef: organizationRef,
@@ -106,7 +131,10 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive,
Metadata: shared.CloneMetadata(req.GetMetadata()),
Metadata: metadata,
}
if description != nil {
wallet.Describable.Description = description
}
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

@@ -5,7 +5,7 @@ import (
"time"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger"
@@ -13,17 +13,19 @@ import (
type Deps struct {
Logger mlogger.Logger
Networks map[string]shared.Network
Networks *rpcclient.Registry
KeyManager keymanager.Manager
Storage storage.Repository
Clock clockpkg.Clock
BalanceCacheTTL time.Duration
RPCTimeout time.Duration
EnsureRepository func(context.Context) error
}
func (d Deps) WithLogger(name string) Deps {
if d.Logger != nil {
d.Logger = d.Logger.Named(name)
if d.Logger == nil {
panic("wallet deps: logger is required")
}
d.Logger = d.Logger.Named(name)
return d
}

View File

@@ -2,123 +2,133 @@ package wallet
import (
"context"
"fmt"
"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/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
logger := deps.Logger
registry := deps.Networks
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
network, ok := registry.Network(networkKey)
if !ok {
logger.Warn("Requested network is not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
)
return nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
}
rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
zap.String("wallet_address", strings.ToLower(strings.TrimSpace(wallet.DepositAddress))),
}
if rpcURL == "" {
logger.Warn("Network rpc url is not configured", logFields...)
return nil, merrors.Internal("network rpc url is not configured")
}
contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" || !common.IsHexAddress(contract) {
logger.Warn("Invalid contract address for balance fetch", logFields...)
return nil, merrors.InvalidArgument("invalid contract address")
}
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
logger.Warn("Invalid wallet address for balance fetch", logFields...)
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()
logger.Info("Fetching on-chain wallet balance", logFields...)
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)
rpcClient, err := registry.RPCClient(networkKey)
if err != nil {
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
return nil, err
}
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
logger.Debug("Calling token decimals", logFields...)
decimals, err := readDecimals(timeoutCtx, rpcClient, contract)
if err != nil {
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
return nil, err
}
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, wallet.DepositAddress)
if err != nil {
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
logger.Info("On-chain wallet balance fetched",
append(logFields,
zap.Uint8("decimals", decimals),
zap.String("balance_raw", bal.String()),
zap.String("balance", dec.String()),
)...,
)
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
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())
func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(common.HexToAddress(token).Hex()),
"data": "0x313ce567",
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, 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")
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, merrors.Internal("decimals decode failed: " + err.Error())
}
if val, ok := values[0].(uint8); ok {
return val, nil
}
return 0, merrors.Internal("decimals returned unexpected type")
return val, nil
}
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())
func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) {
tokenAddr := common.HexToAddress(token)
walletAddr := common.HexToAddress(wallet)
addr := strings.TrimPrefix(walletAddr.Hex(), "0x")
if len(addr) < 64 {
addr = strings.Repeat("0", 64-len(addr)) + addr
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
call := map[string]string{
"to": strings.ToLower(tokenAddr.Hex()),
"data": "0x70a08231" + addr,
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
values, err := tokenABI.Unpack("balanceOf", out)
if err != nil || len(values) == 0 {
return nil, merrors.Internal("failed to unpack balanceOf")
bigVal, err := shared.DecodeHexBig(hexResp)
if err != nil {
return nil, merrors.Internal("balanceOf decode failed: " + err.Error())
}
raw, ok := values[0].(*big.Int)
if !ok {
return nil, merrors.Internal("balanceOf returned unexpected type")
}
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
return bigVal, 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

@@ -1,8 +1,11 @@
package wallet
import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress,
}
name := strings.TrimSpace(wallet.Name)
if name == "" {
name = strings.TrimSpace(wallet.Metadata["name"])
}
if name == "" {
name = wallet.WalletRef
}
description := ""
switch {
case wallet.Description != nil:
description = strings.TrimSpace(*wallet.Description)
default:
description = strings.TrimSpace(wallet.Metadata["description"])
}
desc := &describablev1.Describable{Name: name}
if description != "" {
desc.Description = &description
}
return &chainv1.ManagedWallet{
WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef,
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
Metadata: shared.CloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
Describable: desc,
}
}

View File

@@ -5,15 +5,15 @@ import (
"errors"
"math/big"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"go.uber.org/zap"
@@ -30,11 +30,11 @@ type TransferExecutor interface {
}
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
return &onChainExecutor{
logger: logger.Named("executor"),
keyManager: keyManager,
clients: map[string]*ethclient.Client{},
clients: clients,
}
}
@@ -42,34 +42,33 @@ type onChainExecutor struct {
logger mlogger.Logger
keyManager keymanager.Manager
mu sync.Mutex
clients map[string]*ethclient.Client
clients *rpcclient.Clients
}
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
if o.keyManager == nil {
o.logger.Error("key manager not configured")
o.logger.Warn("key manager not configured")
return "", executorInternal("key manager is not configured", nil)
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
o.logger.Warn("network rpc url missing", zap.String("network", network.Name))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
o.logger.Error("transfer context missing")
o.logger.Warn("transfer context missing")
return "", executorInvalid("transfer context missing")
}
if strings.TrimSpace(source.KeyReference) == "" {
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference")
}
if strings.TrimSpace(source.DepositAddress) == "" {
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing deposit address")
}
if !common.IsHexAddress(destinationAddress) {
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
return "", executorInvalid("invalid destination address " + destinationAddress)
}
@@ -80,11 +79,15 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
zap.String("destination", strings.ToLower(destinationAddress)),
)
client, err := o.getClient(ctx, rpcURL)
client, err := o.clients.Client(network.Name)
if err != nil {
o.logger.Warn("failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
return "", err
}
rpcClient, err := o.clients.RPCClient(network.Name)
if err != nil {
o.logger.Warn("failed to initialise rpc client",
zap.String("network", network.Name),
zap.String("rpc_url", rpcURL),
zap.Error(err),
)
return "", err
@@ -98,10 +101,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
if err != nil {
o.logger.Warn("failed to fetch nonce",
o.logger.Warn("failed to fetch nonce", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
zap.Error(err),
)
return "", executorInternal("failed to fetch nonce", err)
}
@@ -135,12 +137,11 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
}
tokenAddress := common.HexToAddress(transfer.ContractAddress)
decimals, err := erc20Decimals(ctx, client, tokenAddress)
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
if err != nil {
o.logger.Warn("failed to read token decimals",
o.logger.Warn("failed to read token decimals", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", transfer.ContractAddress),
zap.Error(err),
)
return "", err
}
@@ -152,10 +153,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
}
amountInt, err := toBaseUnits(amount.Amount, decimals)
if err != nil {
o.logger.Warn("failed to convert amount to base units",
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("amount", amount.Amount),
zap.Error(err),
)
return "", err
}
@@ -188,18 +188,16 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
if err != nil {
o.logger.Warn("failed to sign transaction",
o.logger.Warn("failed to sign transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
zap.Error(err),
)
return "", err
}
if err := client.SendTransaction(ctx, signedTx); err != nil {
o.logger.Warn("failed to send transaction",
o.logger.Warn("failed to send transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.Error(err),
)
return "", executorInternal("failed to send transaction", err)
}
@@ -214,30 +212,6 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
return txHash, nil
}
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
o.mu.Lock()
client, ok := o.clients[rpcURL]
o.mu.Unlock()
if ok {
return client, nil
}
c, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
}
o.mu.Lock()
defer o.mu.Unlock()
if existing, ok := o.clients[rpcURL]; ok {
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
c.Close()
return existing, nil
}
o.clients[rpcURL] = c
return c, nil
}
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
if strings.TrimSpace(txHash) == "" {
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
@@ -249,7 +223,7 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
return nil, executorInvalid("network rpc url is not configured")
}
client, err := o.getClient(ctx, rpcURL)
client, err := o.clients.Client(network.Name)
if err != nil {
return nil, err
}
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
}
]`
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
callData, err := erc20ABI.Pack("decimals")
if err != nil {
return 0, executorInternal("failed to encode decimals call", err)
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(token.Hex()),
"data": "0x313ce567",
}
msg := ethereum.CallMsg{
To: &token,
Data: callData,
}
output, err := client.CallContract(ctx, msg, nil)
if err != nil {
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, executorInternal("decimals call failed", err)
}
values, err := erc20ABI.Unpack("decimals", output)
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, executorInternal("failed to unpack decimals", err)
return 0, executorInternal("decimals decode failed", err)
}
if len(values) == 0 {
return 0, executorInternal("decimals call returned no data", nil)
}
decimals, ok := values[0].(uint8)
if !ok {
return 0, executorInternal("decimals call returned unexpected type", nil)
}
return decimals, nil
return val, nil
}
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {

View File

@@ -4,6 +4,7 @@ import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
clockpkg "github.com/tech/sendico/pkg/clock"
)
@@ -25,6 +26,13 @@ func WithTransferExecutor(executor TransferExecutor) Option {
}
}
// WithRPCClients configures pre-initialised RPC clients.
func WithRPCClients(clients *rpcclient.Clients) Option {
return func(s *Service) {
s.rpcClients = clients
}
}
// WithNetworks configures supported blockchain networks.
func WithNetworks(networks []shared.Network) Option {
return func(s *Service) {

View File

@@ -0,0 +1,199 @@
package rpcclient
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Clients holds pre-initialised RPC clients keyed by network name.
type Clients struct {
logger mlogger.Logger
clients map[string]clientEntry
}
type clientEntry struct {
eth *ethclient.Client
rpc *rpc.Client
}
// Prepare dials all configured networks up front and returns a ready-to-use client set.
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) {
if logger == nil {
return nil, merrors.Internal("rpc clients: logger is required")
}
clientLogger := logger.Named("rpc_client")
result := &Clients{
logger: clientLogger,
clients: make(map[string]clientEntry),
}
for _, network := range networks {
name := strings.ToLower(strings.TrimSpace(network.Name))
rpcURL := strings.TrimSpace(network.RPCURL)
if name == "" {
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
continue
}
if rpcURL == "" {
result.Close()
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
clientLogger.Warn("rpc url missing", zap.String("network", name))
return nil, err
}
fields := []zap.Field{
zap.String("network", name),
}
clientLogger.Info("initialising rpc client", fields...)
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
httpClient := &http.Client{
Transport: &loggingRoundTripper{
logger: clientLogger,
network: name,
endpoint: rpcURL,
base: http.DefaultTransport,
},
}
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
cancel()
if err != nil {
result.Close()
clientLogger.Warn("failed to dial rpc endpoint", append(fields, zap.Error(err))...)
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
}
client := ethclient.NewClient(rpcCli)
result.clients[name] = clientEntry{
eth: client,
rpc: rpcCli,
}
clientLogger.Info("rpc client ready", fields...)
}
if len(result.clients) == 0 {
clientLogger.Warn("No rpc clients were initialised")
return nil, merrors.InvalidArgument("no rpc clients initialised")
} else {
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
}
return result, nil
}
// Client returns a prepared client for the given network name.
func (c *Clients) Client(network string) (*ethclient.Client, error) {
if c == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.eth == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
}
return entry.eth, nil
}
// RPCClient returns the raw RPC client for low-level calls.
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
if c == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.rpc == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
}
return entry.rpc, nil
}
// Close tears down all RPC clients, logging each close.
func (c *Clients) Close() {
if c == nil {
return
}
for name, entry := range c.clients {
if entry.rpc != nil {
entry.rpc.Close()
} else if entry.eth != nil {
entry.eth.Close()
}
if c.logger != nil {
c.logger.Info("rpc client closed", zap.String("network", name))
}
}
}
type loggingRoundTripper struct {
logger mlogger.Logger
network string
endpoint string
base http.RoundTripper
}
func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if l.base == nil {
l.base = http.DefaultTransport
}
var reqBody []byte
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
reqBody = raw
req.Body = io.NopCloser(strings.NewReader(string(raw)))
}
fields := []zap.Field{
zap.String("network", l.network),
zap.String("rpc_endpoint", l.endpoint),
}
if len(reqBody) > 0 {
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
}
l.logger.Debug("rpc request", fields...)
resp, err := l.base.RoundTrip(req)
if err != nil {
l.logger.Warn("rpc http request failed", append(fields, zap.Error(err))...)
return nil, err
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
respFields := append(fields,
zap.Int("status_code", resp.StatusCode),
)
if len(bodyBytes) > 0 {
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
}
if resp.StatusCode >= 400 {
l.logger.Warn("RPC response error", respFields...)
} else {
// Log response content so downstream parse failures can be inspected without debug logs.
l.logger.Warn("RPC response", respFields...)
}
return resp, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}

View File

@@ -0,0 +1,54 @@
package rpcclient
import (
"strings"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
)
// Registry binds static network metadata with prepared RPC clients.
type Registry struct {
networks map[string]shared.Network
clients *Clients
}
// NewRegistry constructs a registry keyed by lower-cased network name.
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
return &Registry{
networks: networks,
clients: clients,
}
}
// Network fetches network metadata by key (case-insensitive).
func (r *Registry) Network(key string) (shared.Network, bool) {
if r == nil || len(r.networks) == 0 {
return shared.Network{}, false
}
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
return n, ok
}
// Client returns the prepared RPC client for the given network name.
func (r *Registry) Client(key string) (*ethclient.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
}
// RPCClient returns the raw RPC client for low-level calls.
func (r *Registry) RPCClient(key string) (*rpc.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key)))
}
// Networks exposes the registry map for iteration when needed.
func (r *Registry) Networks() map[string]shared.Network {
return r.networks
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/pkg/api/routers"
@@ -38,11 +39,13 @@ type Service struct {
settings CacheSettings
networks map[string]shared.Network
serviceWallet shared.ServiceWallet
keyManager keymanager.Manager
executor TransferExecutor
commands commands.Registry
networks map[string]shared.Network
serviceWallet shared.ServiceWallet
keyManager keymanager.Manager
executor TransferExecutor
rpcClients *rpcclient.Clients
networkRegistry *rpcclient.Registry
commands commands.Registry
chainv1.UnimplementedChainGatewayServiceServer
}
@@ -73,6 +76,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.networks = map[string]shared.Network{}
}
svc.settings = svc.settings.withDefaults()
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
svc.commands = commands.NewRegistry(commands.RegistryDeps{
Wallet: commandsWalletDeps(svc),
@@ -131,11 +135,12 @@ func (s *Service) ensureRepository(ctx context.Context) error {
func commandsWalletDeps(s *Service) wallet.Deps {
return wallet.Deps{
Logger: s.logger.Named("command"),
Networks: s.networks,
Networks: s.networkRegistry,
KeyManager: s.keyManager,
Storage: s.storage,
Clock: s.clock,
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository,
}
}
@@ -143,9 +148,10 @@ func commandsWalletDeps(s *Service) wallet.Deps {
func commandsTransferDeps(s *Service) transfer.Deps {
return transfer.Deps{
Logger: s.logger.Named("transfer_cmd"),
Networks: s.networks,
Networks: s.networkRegistry,
Storage: s.storage,
Clock: s.clock,
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository,
LaunchExecution: s.launchTransferExecution,
}

View File

@@ -3,15 +3,18 @@ package gateway
import "time"
const defaultWalletBalanceCacheTTL = 120 * time.Second
const defaultRPCRequestTimeout = 15 * time.Second
// CacheSettings holds tunable gateway behaviour.
type CacheSettings struct {
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
}
func defaultSettings() CacheSettings {
return CacheSettings{
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
}
}
@@ -19,6 +22,9 @@ func (s CacheSettings) withDefaults() CacheSettings {
if s.WalletBalanceCacheTTLSeconds <= 0 {
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
}
if s.RPCRequestTimeoutSeconds <= 0 {
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
}
return s
}
@@ -28,3 +34,10 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
}
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
}
func (s CacheSettings) rpcTimeout() time.Duration {
if s.RPCRequestTimeoutSeconds <= 0 {
return defaultRPCRequestTimeout
}
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
}

View File

@@ -0,0 +1,49 @@
package shared
import (
"errors"
"math/big"
"strings"
)
var (
errHexEmpty = errors.New("hex value is empty")
errHexInvalid = errors.New("invalid hex number")
errHexOutOfRange = errors.New("hex number out of range")
)
// DecodeHexBig parses a hex string that may include leading zero digits.
func DecodeHexBig(input string) (*big.Int, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return nil, errHexEmpty
}
noPrefix := strings.TrimPrefix(trimmed, "0x")
if noPrefix == "" {
return nil, errHexEmpty
}
value := strings.TrimLeft(noPrefix, "0")
if value == "" {
return big.NewInt(0), nil
}
val := new(big.Int)
if _, ok := val.SetString(value, 16); !ok {
return nil, errHexInvalid
}
return val, nil
}
// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros.
func DecodeHexUint8(input string) (uint8, error) {
val, err := DecodeHexBig(input)
if err != nil {
return 0, err
}
if val == nil {
return 0, errHexInvalid
}
if val.BitLen() > 8 {
return 0, errHexOutOfRange
}
return uint8(val.Uint64()), nil
}

View File

@@ -0,0 +1,16 @@
package shared
import "testing"
func TestDecodeHexUint8_LeadingZeros(t *testing.T) {
t.Parallel()
const resp = "0x0000000000000000000000000000000000000000000000000000000000000006"
val, err := DecodeHexUint8(resp)
if err != nil {
t.Fatalf("DecodeHexUint8 error: %v", err)
}
if val != 6 {
t.Fatalf("DecodeHexUint8 value = %d, want 6", val)
}
}

View File

@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
defer cancel()
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
}
}(transferRef, sourceWalletRef, network)
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
@@ -19,7 +20,8 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"`
storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -77,6 +79,15 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))

View File

@@ -11,8 +11,8 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -30,7 +30,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -214,12 +214,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -11,8 +11,8 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,7 +33,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -51,5 +51,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -214,12 +214,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -33,7 +33,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
@@ -52,7 +52,7 @@ require (
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -99,8 +99,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -227,12 +227,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -17,6 +17,8 @@ import (
// Client exposes typed helpers around the payment orchestrator gRPC API.
type Client interface {
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
@@ -29,6 +31,8 @@ type Client interface {
type grpcOrchestratorClient interface {
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
@@ -97,6 +101,18 @@ func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestrator
return c.client.QuotePayment(ctx, req)
}
func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.QuotePayments(ctx, req)
}
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.InitiatePayments(ctx, req)
}
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()

View File

@@ -9,6 +9,8 @@ import (
// Fake implements Client for tests.
type Fake struct {
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
QuotePaymentsFn func(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
@@ -26,6 +28,20 @@ func (f *Fake) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymen
return &orchestratorv1.QuotePaymentResponse{}, nil
}
func (f *Fake) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
if f.QuotePaymentsFn != nil {
return f.QuotePaymentsFn(ctx, req)
}
return &orchestratorv1.QuotePaymentsResponse{}, nil
}
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
if f.InitiatePaymentsFn != nil {
return f.InitiatePaymentsFn(ctx, req)
}
return &orchestratorv1.InitiatePaymentsResponse{}, nil
}
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
if f.InitiatePaymentFn != nil {
return f.InitiatePaymentFn(ctx, req)

View File

@@ -61,3 +61,6 @@ card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -24,8 +24,8 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -45,7 +45,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -62,5 +62,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -215,12 +215,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -46,6 +46,7 @@ type config struct {
Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
}
type clientConfig struct {
@@ -159,6 +160,9 @@ func (i *Imp) Start() error {
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
return orchestrator.NewService(logger, repo, opts...), nil
}
@@ -323,3 +327,19 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
}
return result
}
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
result := make(map[string]string, len(src))
for key, account := range src {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(account)
if k == "" || v == "" {
continue
}
result[k] = v
}
return result
}

View File

@@ -61,6 +61,13 @@ func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
}
}
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
return &quotePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("quote_payments"),
}
}
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
return &initiatePaymentCommand{
engine: f.engine,
@@ -68,6 +75,13 @@ func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
}
}
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
return &initiatePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payments"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,

View File

@@ -5,9 +5,7 @@ import (
"time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
@@ -20,13 +18,14 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
return model.PaymentIntent{}
}
intent := model.PaymentIntent{
Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()),
Amount: cloneMoney(src.GetAmount()),
RequiresFX: src.GetRequiresFx(),
FeePolicy: src.GetFeePolicy(),
Attributes: cloneMetadata(src.GetAttributes()),
Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()),
Amount: cloneMoney(src.GetAmount()),
RequiresFX: src.GetRequiresFx(),
FeePolicy: src.GetFeePolicy(),
SettlementMode: src.GetSettlementMode(),
Attributes: cloneMetadata(src.GetAttributes()),
}
if src.GetFx() != nil {
intent.FX = fxIntentFromProto(src.GetFx())
@@ -153,13 +152,14 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
intent := &orchestratorv1.PaymentIntent{
Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination),
Amount: cloneMoney(src.Amount),
RequiresFx: src.RequiresFX,
FeePolicy: src.FeePolicy,
Attributes: cloneMetadata(src.Attributes),
Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination),
Amount: cloneMoney(src.Amount),
RequiresFx: src.RequiresFX,
FeePolicy: src.FeePolicy,
SettlementMode: src.SettlementMode,
Attributes: cloneMetadata(src.Attributes),
}
if src.FX != nil {
intent.Fx = protoFXIntentFromModel(src.FX)
@@ -411,74 +411,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
}
return nil
}
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
switch code {
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
return model.PaymentFailureCodeBalance
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
return model.PaymentFailureCodeLedger
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
return model.PaymentFailureCodeFX
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
return model.PaymentFailureCodeChain
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
return model.PaymentFailureCodeFees
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
return model.PaymentFailureCodePolicy
default:
return model.PaymentFailureCodeUnspecified
}
}
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
if src == nil || dst == nil {
return merrors.InvalidArgument("payment payload is required")
}
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
dst.Intent = intentFromProto(src.GetIntent())
dst.State = modelStateFromProto(src.GetState())
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
if src.GetCardPayout() != nil {
dst.CardPayout = &model.CardPayout{
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
}
}
return nil
}
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
if src == nil {
return nil
}
return &model.ExecutionRefs{
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
}
}
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
if req == nil {
return &paginationv1.CursorPageRequest{}
}
if req.GetPage() == nil {
return &paginationv1.CursorPageRequest{}
}
return req.GetPage()
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
@@ -66,6 +67,177 @@ func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.Q
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
type quotePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := req.GetIntents()
if len(intents) == 0 {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
}
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteReq := &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
Intent: intent,
PreviewOnly: req.GetPreviewOnly(),
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quotes = append(quotes, quote)
expires = append(expires, expiresAt)
}
aggregate, err := aggregatePaymentQuotes(quotes)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
}
expiresAt, ok := minQuoteExpiry(expires)
if !ok {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
}
quoteRef := ""
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef = primitive.NewObjectID().Hex()
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: quoteRef,
Aggregate: aggregate,
Quotes: quotes,
})
}
type initiatePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
if quoteRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
}
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := record.Intents
quotes := record.Quotes
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
intents = []model.PaymentIntent{record.Intent}
}
if len(quotes) == 0 && record.Quote != nil {
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
}
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments := make([]*orchestratorv1.Payment, 0, len(intents))
for i := range intents {
intentProto := protoIntentFromModel(intents[i])
if err := requireNonNilIntent(intentProto); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto := modelQuoteToProto(quotes[i])
if quoteProto == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing))
continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments = append(payments, toProtoPayment(entity))
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger

View File

@@ -145,7 +145,7 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
}
}
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) {
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
if pay == nil {
return nil, nil
}
@@ -166,7 +166,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
}
}
adjustDebit := func(m *moneyv1.Money) {
applyChargeToDebit := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
if err != nil || converted == nil {
return
@@ -176,7 +176,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
}
}
adjustSettlement := func(m *moneyv1.Money) {
applyChargeToSettlement := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
if err != nil || converted == nil {
return
@@ -186,12 +186,22 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
}
}
adjustDebit(fee)
adjustSettlement(fee)
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
// Sender pays the fee: keep settlement fixed, increase debit.
applyChargeToDebit(fee)
default:
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
applyChargeToSettlement(fee)
}
if network != nil && network.GetNetworkFee() != nil {
adjustDebit(network.GetNetworkFee())
adjustSettlement(network.GetNetworkFee())
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
applyChargeToDebit(network.GetNetworkFee())
default:
applyChargeToSettlement(network.GetNetworkFee())
}
}
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
@@ -204,20 +214,6 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
return decimal.NewFromString(m.GetAmount())
}
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
if reference == nil || candidate == nil {
return nil, nil
}
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
return nil, nil
}
value, err := decimal.NewFromString(candidate.GetAmount())
if err != nil {
return nil, err
}
return &value, nil
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{
Currency: currency,
@@ -383,6 +379,22 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
return breakdown
}
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
if account == "" || len(lines) == 0 {
return lines
}
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
continue
}
line.LedgerAccountRef = account
}
return lines
}
func moneyEquals(a, b *moneyv1.Money) bool {
if a == nil || b == nil {
return false

View File

@@ -7,6 +7,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestResolveTradeAmountsBuyBase(t *testing.T) {
@@ -47,11 +48,32 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
},
}
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote)
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED)
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
}
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" {
t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" {
t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
}
}
func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
pay := &moneyv1.Money{Currency: "USDT", Amount: "100"}
settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32
fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100
fxQuote := &oraclev1.Quote{
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
Price: &moneyv1.Decimal{
Value: "79.32",
},
}
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
}
if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" {
t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
}
}

View File

@@ -113,6 +113,24 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
}
}
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
func WithFeeLedgerAccounts(routes map[string]string) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
for k, v := range routes {
key := strings.ToLower(strings.TrimSpace(k))
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
s.deps.feeLedgerAccounts[key] = val
}
}
}
// WithClock overrides the default clock.
func WithClock(clock clockpkg.Clock) Option {
return func(s *Service) {

View File

@@ -0,0 +1,145 @@
package orchestrator
import (
"fmt"
"sort"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func perIntentIdempotencyKey(base string, index int, total int) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
if total <= 1 {
return base
}
return fmt.Sprintf("%s:%d", base, index+1)
}
func minQuoteExpiry(expires []time.Time) (time.Time, bool) {
var min time.Time
for _, exp := range expires {
if exp.IsZero() {
continue
}
if min.IsZero() || exp.Before(min) {
min = exp
}
}
if min.IsZero() {
return time.Time{}, false
}
return min, true
}
func aggregatePaymentQuotes(quotes []*orchestratorv1.PaymentQuote) (*orchestratorv1.PaymentQuoteAggregate, error) {
if len(quotes) == 0 {
return nil, nil
}
debitTotals := map[string]decimal.Decimal{}
settlementTotals := map[string]decimal.Decimal{}
feeTotals := map[string]decimal.Decimal{}
networkTotals := map[string]decimal.Decimal{}
for _, quote := range quotes {
if quote == nil {
continue
}
if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil {
return nil, err
}
if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil {
return nil, err
}
if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil {
return nil, err
}
if nf := quote.GetNetworkFee(); nf != nil {
if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil {
return nil, err
}
}
}
return &orchestratorv1.PaymentQuoteAggregate{
DebitAmounts: totalsToMoney(debitTotals),
ExpectedSettlementAmounts: totalsToMoney(settlementTotals),
ExpectedFeeTotals: totalsToMoney(feeTotals),
NetworkFeeTotals: totalsToMoney(networkTotals),
}, nil
}
func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error {
if money == nil {
return nil
}
currency := strings.TrimSpace(money.GetCurrency())
if currency == "" {
return nil
}
amount, err := decimal.NewFromString(money.GetAmount())
if err != nil {
return err
}
if current, ok := totals[currency]; ok {
totals[currency] = current.Add(amount)
return nil
}
totals[currency] = amount
return nil
}
func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money {
if len(totals) == 0 {
return nil
}
currencies := make([]string, 0, len(totals))
for currency := range totals {
currencies = append(currencies, currency)
}
sort.Strings(currencies)
result := make([]*moneyv1.Money, 0, len(currencies))
for _, currency := range currencies {
amount := totals[currency]
result = append(result, &moneyv1.Money{
Amount: amount.String(),
Currency: currency,
})
}
return result
}
func intentsFromProto(intents []*orchestratorv1.PaymentIntent) []model.PaymentIntent {
if len(intents) == 0 {
return nil
}
result := make([]model.PaymentIntent, 0, len(intents))
for _, intent := range intents {
result = append(result, intentFromProto(intent))
}
return result
}
func quoteSnapshotsFromProto(quotes []*orchestratorv1.PaymentQuote) []*model.PaymentQuoteSnapshot {
if len(quotes) == 0 {
return nil
}
result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes))
for _, quote := range quotes {
if quote == nil {
continue
}
if snapshot := quoteSnapshotToModel(quote); snapshot != nil {
result = append(result, snapshot)
}
}
return result
}

View File

@@ -0,0 +1,102 @@
package orchestrator
import (
"testing"
"time"
"github.com/shopspring/decimal"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestAggregatePaymentQuotes(t *testing.T) {
quotes := []*orchestratorv1.PaymentQuote{
{
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"},
ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"},
NetworkFee: &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"},
},
},
{
DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"},
ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"},
ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"},
NetworkFee: &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"},
},
},
}
agg, err := aggregatePaymentQuotes(quotes)
if err != nil {
t.Fatalf("aggregatePaymentQuotes returned error: %v", err)
}
assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"})
assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"})
assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"})
assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"})
}
func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) {
quotes := []*orchestratorv1.PaymentQuote{
{
DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"},
},
}
if _, err := aggregatePaymentQuotes(quotes); err == nil {
t.Fatal("expected error for invalid amount")
}
}
func TestMinQuoteExpiry(t *testing.T) {
now := time.Now().UTC()
later := now.Add(10 * time.Minute)
earliest := now.Add(5 * time.Minute)
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
if !ok {
t.Fatal("expected min expiry to be set")
}
if !min.Equal(earliest) {
t.Fatalf("expected min expiry %v, got %v", earliest, min)
}
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
t.Fatal("expected min expiry to be unset")
}
}
func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) {
t.Helper()
got := make(map[string]decimal.Decimal, len(list))
for _, item := range list {
if item == nil {
continue
}
val, err := decimal.NewFromString(item.GetAmount())
if err != nil {
t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err)
}
got[item.GetCurrency()] = val
}
if len(got) != len(expected) {
t.Fatalf("expected %d totals, got %d", len(expected), len(got))
}
for currency, amount := range expected {
val, err := decimal.NewFromString(amount)
if err != nil {
t.Fatalf("invalid expected amount %q: %v", amount, err)
}
gotVal, ok := got[currency]
if !ok {
t.Fatalf("missing currency %s", currency)
}
if !gotVal.Equal(val) {
t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String())
}
}
}

View File

@@ -52,7 +52,9 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
feeLines := cloneFeeLines(feeQuote.GetLines())
s.assignFeeLedgerAccounts(intent, feeLines)
feeTotal := extractFeeTotal(feeLines, feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
@@ -63,13 +65,13 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef))
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode())
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeLines: feeLines,
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
@@ -207,3 +209,53 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
}
return quoteToProto(quote), nil
}
func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string {
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
return ""
}
key := s.gatewayKeyFromIntent(intent)
if key == "" {
return ""
}
return strings.TrimSpace(s.deps.feeLedgerAccounts[key])
}
func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) {
account := s.feeLedgerAccountForIntent(intent)
key := s.gatewayKeyFromIntent(intent)
missing := 0
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
missing++
}
}
if missing == 0 {
return
}
if account == "" {
s.logger.Debug("no fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing))
return
}
assignLedgerAccounts(lines, account)
s.logger.Debug("applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing))
}
func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string {
if intent == nil {
return ""
}
key := strings.TrimSpace(intent.GetAttributes()["gateway"])
if key == "" {
if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil {
key = defaultCardGateway
}
}
return strings.ToLower(key)
}

View File

@@ -41,12 +41,13 @@ type Service struct {
}
type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
}
type handlerSet struct {
@@ -116,12 +117,24 @@ func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePay
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
}
// QuotePayments aggregates downstream quotes for multiple intents.
func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
}
// InitiatePayment captures a payment intent and reserves funds orchestration.
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
}
// InitiatePayments executes multiple payments using a stored quote reference.
func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req)
}
// CancelPayment attempts to cancel an in-flight payment.
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
s.ensureHandlers()

View File

@@ -48,7 +48,8 @@ func TestRequireIdempotencyKey(t *testing.T) {
func TestNewPayment(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED,
}
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
@@ -58,6 +59,9 @@ func TestNewPayment(t *testing.T) {
if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" {
t.Fatalf("intent not copied")
}
if p.Intent.SettlementMode != orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED {
t.Fatalf("settlement mode not preserved")
}
if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" {
t.Fatalf("quote not copied")
}

View File

@@ -11,6 +11,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
// PaymentKind captures the orchestrator intent type.
@@ -124,14 +125,15 @@ type FXIntent struct {
// PaymentIntent models the requested payment operation.
type PaymentIntent struct {
Kind PaymentKind `bson:"kind" json:"kind"`
Source PaymentEndpoint `bson:"source" json:"source"`
Destination PaymentEndpoint `bson:"destination" json:"destination"`
Amount *moneyv1.Money `bson:"amount" json:"amount"`
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
Kind PaymentKind `bson:"kind" json:"kind"`
Source PaymentEndpoint `bson:"source" json:"source"`
Destination PaymentEndpoint `bson:"destination" json:"destination"`
Amount *moneyv1.Money `bson:"amount" json:"amount"`
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
}
// PaymentQuoteSnapshot stores the latest quote info.

View File

@@ -12,10 +12,12 @@ type PaymentQuoteRecord struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
Intent PaymentIntent `bson:"intent" json:"intent"`
Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"`
Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"`
Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"`
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
}
// Collection implements storable.Storable.

View File

@@ -73,6 +73,16 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er
quote.Intent.Attributes[k] = strings.TrimSpace(v)
}
}
if len(quote.Intents) > 0 {
for i := range quote.Intents {
if quote.Intents[i].Attributes == nil {
continue
}
for k, v := range quote.Intents[i].Attributes {
quote.Intents[i].Attributes[k] = strings.TrimSpace(v)
}
}
}
quote.Update()
filter := repository.OrgFilter(quote.OrganizationRef).And(

View File

@@ -47,12 +47,7 @@ func Error[T any](logger mlogger.Logger, service mservice.Type, code codes.Code,
if err != nil {
fields = append(fields, zap.Error(err))
}
logFn := logger.Warn
switch code {
case codes.Internal, codes.DataLoss, codes.Unavailable:
logFn = logger.Error
}
logFn("gRPC request failed", fields...)
logger.Warn("gRPC request failed", fields...)
msg := message(err)
switch {

View File

@@ -6,9 +6,9 @@ import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
@@ -32,7 +32,7 @@ func TestUnarySuccess(t *testing.T) {
return Success(resp)
}
unary := Unary[testRequest, testResponse](logger, mservice.Type("test"), handler)
unary := Unary(logger, mservice.Type("test"), handler)
resp, err := unary(context.Background(), &testRequest{Value: "hello"})
require.NoError(t, err)
require.NotNil(t, resp)

View File

@@ -37,7 +37,9 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error)
{Field: "clientId", Sort: ri.Asc},
{Field: "deviceId", Sort: ri.Asc},
},
Unique: true,
Unique: true,
Name: "unique_active_session",
PartialFilter: repository.Filter(IsRevokedField, false),
}); err != nil {
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
return nil, err

View File

@@ -10,23 +10,29 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/merrors"
factory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
db, _, cleanup := setupTestDBWithMongo(t)
return db, cleanup
}
func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
// mark as helper for better test failure reporting
t.Helper()
@@ -62,7 +68,7 @@ func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
_ = mongoContainer.Terminate(termCtx)
}
return db, cleanup
return db, database, cleanup
}
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
@@ -332,6 +338,63 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
_, err = db.GetByCRT(ctx, secondCRT)
require.NoError(t, err)
})
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
userID := primitive.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
// Global revoke (deviceID empty) — all tokens should be revoked
err = db.RevokeAll(ctx, userID, "")
require.NoError(t, err)
var revoked model.RefreshToken
err = db.Get(ctx, *firstToken.GetID(), &revoked)
require.NoError(t, err)
assert.True(t, revoked.IsRevoked)
// Creating a new token for the same account/client/device must succeed and produce an active token
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
err = db.Create(ctx, reissueToken)
require.NoError(t, err)
newCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "new_token_after_revocation",
}
_, err = db.GetByCRT(ctx, newCRT)
require.NoError(t, err)
// Old token must remain unusable
oldCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "revoked_token_123",
}
_, err = db.GetByCRT(ctx, oldCRT)
assert.Error(t, err)
// Both records exist: revoked + new active
query := repository.Query().
Filter(repository.AccountField(), userID).
And(
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
)
ids, err := db.Repository.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 2)
})
}
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
@@ -637,3 +700,29 @@ func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
})
}
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
db, database, cleanup := setupTestDBWithMongo(t)
defer cleanup()
ctx := context.Background()
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == "unique_active_session" {
found = true
assert.Equal(t, true, idx["unique"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"isRevoked": false}, partial)
}
}
assert.True(t, found, "unique_active_session index not found")
}

View File

@@ -41,6 +41,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if def.Name != "" {
opts.SetName(def.Name)
}
if def.PartialFilter != nil {
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
}
_, err := r.collection.Indexes().CreateOne(
context.Background(),

View File

@@ -0,0 +1,83 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestCreateIndex_WithPartialFilter(t *testing.T) {
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer startCancel()
mongoContainer, err := mongodb.Run(startCtx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
)
require.NoError(t, err)
mongoURI, err := mongoContainer.ConnectionString(startCtx)
require.NoError(t, err)
client, err := mongo.Connect(startCtx, options.Client().ApplyURI(mongoURI))
require.NoError(t, err)
defer client.Disconnect(context.Background())
database := client.Database("test_partial_index_" + t.Name())
defer database.Drop(context.Background())
repo := repository.CreateMongoRepository(database, "partial_index_items")
def := &ri.Definition{
Keys: []ri.Key{
{Field: "field", Sort: ri.Asc},
},
Unique: true,
Name: "partial_unique_field_true",
PartialFilter: repository.Filter("flag", treu),
}
require.NoError(t, repo.CreateIndex(def))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == def.Name {
found = true
assert.Equal(t, true, idx["unique"])
assert.Equal(t, bson.M{"field": int32(1)}, idx["key"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"flag": true}, partial)
}
}
assert.True(t, found, "partial unique index was not created")
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer termCancel()
_ = mongoContainer.Terminate(termCtx)
}

View File

@@ -1,5 +1,7 @@
package repository
import "github.com/tech/sendico/pkg/db/repository/builder"
type Sort int8
const (
@@ -14,8 +16,9 @@ type Key struct {
}
type Definition struct {
Keys []Key // mandatory, at least one element
Unique bool // unique constraint?
TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name
Keys []Key // mandatory, at least one element
Unique bool // unique constraint?
TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
}

View File

@@ -9,7 +9,7 @@ require (
github.com/google/uuid v1.6.0
github.com/mattn/go-colorable v0.1.14
github.com/mitchellh/mapstructure v1.5.0
github.com/nats-io/nats.go v1.47.0
github.com/nats-io/nats.go v1.48.0
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.33.0
@@ -17,8 +17,8 @@ require (
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
)
require (
@@ -93,6 +93,6 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -106,8 +106,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -267,14 +267,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,9 +8,10 @@ import (
)
type CryptoAddressPaymentData struct {
Address string `bson:"address" json:"address"`
Network string `bson:"network" json:"network"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
Currency Currency `bson:"currency" json:"currency"`
Address string `bson:"address" json:"address"`
Network string `bson:"network" json:"network"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
}
func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) {

View File

@@ -3,12 +3,13 @@ package model
type Currency string
const (
CurrencyEUR Currency = "EUR" // Euro
CurrencyUSD Currency = "USD" // US Dollar
CurrencyRUB Currency = "RUB" // Russian Ruble
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
CurrencyPLN Currency = "PLN" // Polish Złoty
CurrencyCZK Currency = "CZK" // Czech Koruna
CurrencyEUR Currency = "EUR" // Euro
CurrencyUSD Currency = "USD" // US Dollar
CurrencyRUB Currency = "RUB" // Russian Ruble
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
CurrencyPLN Currency = "PLN" // Polish Złoty
CurrencyCZK Currency = "CZK" // Czech Koruna
CurrencyUSDT Currency = "USDT" // Czech Koruna
)
// All supported currencies
@@ -19,6 +20,7 @@ var SupportedCurrencies = []Currency{
CurrencyUAH,
CurrencyPLN,
CurrencyCZK,
CurrencyUSDT,
}
type Amount struct {

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
package common.describable.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/describable/v1;describablev1";
// Describable captures a name/description pair reusable across resources.
message Describable {
string name = 1;
optional string description = 2;
}

View File

@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
import "google/protobuf/timestamp.proto";
import "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto";
import "common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets.
enum ChainNetwork {
@@ -14,6 +15,8 @@ enum ChainNetwork {
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
CHAIN_NETWORK_ARBITRUM_ONE = 2;
CHAIN_NETWORK_OTHER_EVM = 3;
CHAIN_NETWORK_TRON_MAINNET = 4;
CHAIN_NETWORK_TRON_NILE = 5;
}
enum ManagedWalletStatus {
@@ -57,6 +60,7 @@ message ManagedWallet {
map<string, string> metadata = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
}
message CreateManagedWalletRequest {
@@ -65,6 +69,7 @@ message CreateManagedWalletRequest {
string owner_ref = 3;
Asset asset = 4;
map<string, string> metadata = 5;
common.describable.v1.Describable describable = 6;
}
message CreateManagedWalletResponse {

View File

@@ -125,6 +125,13 @@ message PaymentQuote {
string quote_ref = 8;
}
message PaymentQuoteAggregate {
repeated common.money.v1.Money debit_amounts = 1;
repeated common.money.v1.Money expected_settlement_amounts = 2;
repeated common.money.v1.Money expected_fee_totals = 3;
repeated common.money.v1.Money network_fee_totals = 4;
}
message ExecutionRefs {
string debit_entry_ref = 1;
string credit_entry_ref = 2;
@@ -172,6 +179,30 @@ message QuotePaymentResponse {
PaymentQuote quote = 1;
}
message QuotePaymentsRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
repeated PaymentIntent intents = 3;
bool preview_only = 4;
}
message QuotePaymentsResponse {
string quote_ref = 1;
PaymentQuoteAggregate aggregate = 2;
repeated PaymentQuote quotes = 3;
}
message InitiatePaymentsRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
string quote_ref = 3;
map<string, string> metadata = 4;
}
message InitiatePaymentsResponse {
repeated Payment payments = 1;
}
message InitiatePaymentRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
@@ -259,6 +290,8 @@ message InitiateConversionResponse {
service PaymentOrchestrator {
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse);
rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse);
rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse);
rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse);
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);

View File

@@ -80,7 +80,7 @@ api:
call_timeout_seconds: 5
insecure: true
default_asset:
chain: ARBITRUM_ONE
chain: TRON_MAINNET
token_symbol: USDT
contract_address: ""
ledger:
@@ -90,8 +90,8 @@ api:
call_timeout_seconds: 5
insecure: true
payment_orchestrator:
address: sendico_payment_orchestrator:50062
address_env: PAYMENT_ORCHESTRATOR_ADDRESS
address: sendico_payments_orchestrator:50062
address_env: PAYMENTS_ADDRESS
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true

View File

@@ -12,9 +12,9 @@ replace github.com/tech/sendico/gateway/chain => ../gateway/chain
require (
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3
@@ -32,7 +32,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/net v0.48.0
google.golang.org/protobuf v1.36.10
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
moul.io/chizap v1.0.3
)
@@ -58,7 +58,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
@@ -103,7 +103,7 @@ require (
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -139,6 +139,6 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
)

View File

@@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
@@ -32,12 +32,12 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
@@ -175,8 +175,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -359,14 +359,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -36,6 +36,27 @@ func (r *QuotePayment) Validate() error {
return nil
}
type QuotePayments struct {
PaymentBase `json:",inline"`
Intents []PaymentIntent `json:"intents"`
PreviewOnly bool `json:"previewOnly"`
}
func (r *QuotePayments) Validate() error {
if err := r.PaymentBase.Validate(); err != nil {
return err
}
if len(r.Intents) == 0 {
return merrors.InvalidArgument("intents are required", "intents")
}
for i := range r.Intents {
if err := r.Intents[i].Validate(); err != nil {
return err
}
}
return nil
}
type InitiatePayment struct {
PaymentBase `json:",inline"`
Intent *PaymentIntent `json:"intent,omitempty"`
@@ -68,3 +89,18 @@ func (r InitiatePayment) Validate() error {
return nil
}
type InitiatePayments struct {
PaymentBase `json:",inline"`
QuoteRef string `json:"quoteRef,omitempty"`
}
func (r InitiatePayments) Validate() error {
if err := r.PaymentBase.Validate(); err != nil {
return err
}
if r.QuoteRef == "" {
return merrors.InvalidArgument("quoteRef is required", "quoteRef")
}
return nil
}

View File

@@ -37,6 +37,8 @@ const (
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
ChainNetworkOtherEVM ChainNetwork = "other_evm"
ChainNetworkTronMainnet ChainNetwork = "tron_mainnet"
ChainNetworkTronNile ChainNetwork = "tron_nile"
)
// InsufficientNetPolicy mirrors the fee engine policy override.

View File

@@ -15,6 +15,12 @@ type PaymentIntent struct {
Attributes map[string]string `json:"attributes,omitempty"`
}
type AssetResolverStub struct{}
func (a *AssetResolverStub) IsSupported(_ string) bool {
return true
}
func (p *PaymentIntent) Validate() error {
// Kind must be set (non-zero)
var zeroKind PaymentKind
@@ -33,7 +39,8 @@ func (p *PaymentIntent) Validate() error {
if p.Amount == nil {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if err := ValidateMoney(p.Amount); err != nil {
//TODO: collect supported currencies and validate against them
if err := ValidateMoney(p.Amount, &AssetResolverStub{}); err != nil {
return err
}

View File

@@ -1,30 +1,77 @@
package srequest
import (
"regexp"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
func ValidateMoney(m *model.Money) error {
if m.Amount == "" {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if m.Currency == "" {
// AssetResolver defines environment-specific supported assets.
// Implementations should check:
// - fiat assets (ISO-4217)
// - crypto assets supported by gateways / FX providers
type AssetResolver interface {
IsSupported(ticker string) bool
}
// Precompile regex for efficiency.
var currencySyntax = regexp.MustCompile(`^[A-Z0-9]{2,10}$`)
// ValidateCurrency validates currency syntax and checks dictionary via assetResolver.
func ValidateCurrency(cur string, assetResolver AssetResolver) error {
// Basic presence
if strings.TrimSpace(cur) == "" {
return merrors.InvalidArgument("currency is required", "intent.currency")
}
if _, err := decimal.NewFromString(m.Amount); err != nil {
return merrors.InvalidArgument("invalid amount decimal", "intent.amount")
// Normalize
cur = strings.ToUpper(strings.TrimSpace(cur))
// Syntax check
if !currencySyntax.MatchString(cur) {
return merrors.InvalidArgument(
"invalid currency format (must be AZ09, length 210)",
"intent.currency",
)
}
if len(m.Currency) != 3 {
return merrors.InvalidArgument("currency must be 3 letters", "intent.currency")
// Dictionary validation
if assetResolver == nil {
return merrors.InvalidArgument("asset resolver is not configured", "intent.currency")
}
for _, c := range m.Currency {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("currency must be uppercase A-Z", "intent.currency")
}
if !assetResolver.IsSupported(cur) {
return merrors.InvalidArgument("unsupported currency/asset", "intent.currency")
}
return nil
}
func ValidateMoney(m *model.Money, assetResolver AssetResolver) error {
if m == nil {
return merrors.InvalidArgument("money is required", "intent.amount")
}
// 1) Basic presence
if strings.TrimSpace(m.Amount) == "" {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
// 2) Validate decimal amount
amount, err := decimal.NewFromString(m.Amount)
if err != nil {
return merrors.InvalidArgument("invalid decimal amount", "intent.amount")
}
if amount.IsNegative() {
return merrors.InvalidArgument("amount must be >= 0", "intent.amount")
}
// 3) Validate currency via helper
if err := ValidateCurrency(m.Currency, assetResolver); err != nil {
return err
}
return nil
@@ -36,31 +83,15 @@ type CurrencyPair struct {
}
func (p *CurrencyPair) Validate() error {
if p.Base == "" {
return merrors.InvalidArgument("base currency is required", "intent.fx.pair.base")
if p == nil {
return merrors.InvalidArgument("currency pair is required", "currncy_pair")
}
if p.Quote == "" {
return merrors.InvalidArgument("quote currency is required", "intent.fx.pair.quote")
if err := ValidateCurrency(p.Base, &AssetResolverStub{}); err != nil {
return merrors.InvalidArgument("invalid base currency in pair: "+err.Error(), "currency_pair.base")
}
if len(p.Base) != 3 {
return merrors.InvalidArgument("base currency must be 3 letters", "intent.fx.pair.base")
if err := ValidateCurrency(p.Quote, &AssetResolverStub{}); err != nil {
return merrors.InvalidArgument("invalid quote currency in pair: "+err.Error(), "currency_pair.quote")
}
if len(p.Quote) != 3 {
return merrors.InvalidArgument("quote currency must be 3 letters", "intent.fx.pair.quote")
}
for _, c := range p.Base {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("base currency must be uppercase A-Z", "intent.fx.pair.base")
}
}
for _, c := range p.Quote {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("quote currency must be uppercase A-Z", "intent.fx.pair.quote")
}
}
return nil
}

View File

@@ -14,3 +14,19 @@ func toMoney(m *moneyv1.Money) *model.Money {
Currency: m.GetCurrency(),
}
}
func toMoneyList(list []*moneyv1.Money) []*model.Money {
if len(list) == 0 {
return nil
}
result := make([]*model.Money, 0, len(list))
for _, item := range list {
if m := toMoney(item); m != nil {
result = append(result, m)
}
}
if len(result) == 0 {
return nil
}
return result
}

View File

@@ -49,6 +49,19 @@ type PaymentQuote struct {
FxQuote *FxQuote `json:"fxQuote,omitempty"`
}
type PaymentQuoteAggregate struct {
DebitAmounts []*model.Money `json:"debitAmounts,omitempty"`
ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"`
ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"`
NetworkFeeTotals []*model.Money `json:"networkFeeTotals,omitempty"`
}
type PaymentQuotes struct {
QuoteRef string `json:"quoteRef,omitempty"`
Aggregate *PaymentQuoteAggregate `json:"aggregate,omitempty"`
Quotes []PaymentQuote `json:"quotes,omitempty"`
}
type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
@@ -63,6 +76,16 @@ type paymentQuoteResponse struct {
Quote *PaymentQuote `json:"quote"`
}
type paymentQuotesResponse struct {
authResponse `json:",inline"`
Quote *PaymentQuotes `json:"quote"`
}
type paymentsResponse struct {
authResponse `json:",inline"`
Payments []Payment `json:"payments"`
}
type paymentResponse struct {
authResponse `json:",inline"`
Payment *Payment `json:"payment"`
@@ -76,6 +99,22 @@ func PaymentQuoteResponse(logger mlogger.Logger, quote *orchestratorv1.PaymentQu
})
}
// PaymentQuotes wraps batch quotes with refreshed access token.
func PaymentQuotesResponse(logger mlogger.Logger, resp *orchestratorv1.QuotePaymentsResponse, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentQuotesResponse{
Quote: toPaymentQuotes(resp),
authResponse: authResponse{AccessToken: *token},
})
}
// Payments wraps a list of payments with refreshed access token.
func PaymentsResponse(logger mlogger.Logger, payments []*orchestratorv1.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentsResponse{
Payments: toPayments(payments),
authResponse: authResponse{AccessToken: *token},
})
}
// Payment wraps a payment with refreshed access token.
func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentResponse{
@@ -158,6 +197,54 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
}
}
func toPaymentQuoteAggregate(q *orchestratorv1.PaymentQuoteAggregate) *PaymentQuoteAggregate {
if q == nil {
return nil
}
return &PaymentQuoteAggregate{
DebitAmounts: toMoneyList(q.GetDebitAmounts()),
ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()),
ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()),
NetworkFeeTotals: toMoneyList(q.GetNetworkFeeTotals()),
}
}
func toPaymentQuotes(resp *orchestratorv1.QuotePaymentsResponse) *PaymentQuotes {
if resp == nil {
return nil
}
quotes := make([]PaymentQuote, 0, len(resp.GetQuotes()))
for _, quote := range resp.GetQuotes() {
if dto := toPaymentQuote(quote); dto != nil {
quotes = append(quotes, *dto)
}
}
if len(quotes) == 0 {
quotes = nil
}
return &PaymentQuotes{
QuoteRef: resp.GetQuoteRef(),
Aggregate: toPaymentQuoteAggregate(resp.GetAggregate()),
Quotes: quotes,
}
}
func toPayments(items []*orchestratorv1.Payment) []Payment {
if len(items) == 0 {
return nil
}
result := make([]Payment, 0, len(items))
for _, item := range items {
if p := toPayment(item); p != nil {
result = append(result, *p)
}
}
if len(result) == 0 {
return nil
}
return result
}
func toPayment(p *orchestratorv1.Payment) *Payment {
if p == nil {
return nil

View File

@@ -2,6 +2,7 @@ package sresponse
import (
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
@@ -26,6 +27,8 @@ type wallet struct {
DepositAddress string `json:"depositAddress"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
@@ -80,6 +83,27 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
token = asset.GetTokenSymbol()
contract = asset.GetContractAddress()
}
name := ""
if d := w.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(w.GetMetadata()["name"])
}
if name == "" {
name = w.GetWalletRef()
}
var description *string
if d := w.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
description = &trimmed
}
}
return wallet{
WalletRef: w.GetWalletRef(),
OrganizationRef: w.GetOrganizationRef(),
@@ -92,6 +116,8 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
DepositAddress: w.GetDepositAddress(),
Status: w.GetStatus().String(),
Metadata: w.GetMetadata(),
Name: name,
Description: description,
CreatedAt: tsToString(w.GetCreatedAt()),
UpdatedAt: tsToString(w.GetUpdatedAt()),
}

View File

@@ -53,9 +53,7 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
pr.logger.Info("Login confirmation code issued",
zap.String("destination", pr.maskEmail(account.Login)),
zap.String("account", account.Login))
pr.logger.Info("Login confirmation code issued", zap.String("destination", pr.maskEmail(account.Login)))
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
}

View File

@@ -216,6 +216,10 @@ func parseChainNetwork(value string) (chainv1.ChainNetwork, error) {
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case "OTHER_EVM", "CHAIN_NETWORK_OTHER_EVM":
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET":
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE":
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
case "", "CHAIN_NETWORK_UNSPECIFIED":
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("chain network must be specified")
default:

View File

@@ -248,12 +248,11 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization,
a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org))
return merrors.Internal("chain gateway client is not configured")
}
asset := *a.chainAsset
req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(),
OwnerRef: org.ID.Hex(),
Asset: &asset,
Asset: a.chainAsset,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,

View File

@@ -288,6 +288,10 @@ func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error)
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case string(srequest.ChainNetworkOtherEVM):
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
case string(srequest.ChainNetworkTronMainnet):
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
case string(srequest.ChainNetworkTronNile):
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
}

View File

@@ -0,0 +1,74 @@
package paymentapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for batch payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when initiating batch payments", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeInitiatePaymentsPayload(r)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
req := &orchestratorv1.InitiatePaymentsRequest{
Meta: &orchestratorv1.RequestMeta{
OrganizationRef: orgRef.Hex(),
},
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
QuoteRef: strings.TrimSpace(payload.QuoteRef),
Metadata: payload.Metadata,
}
resp, err := a.client.InitiatePayments(ctx, req)
if err != nil {
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.PaymentsResponse(a.logger, resp.GetPayments(), token)
}
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
defer r.Body.Close()
payload := &srequest.InitiatePayments{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -67,6 +67,62 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
return sresponse.PaymentQuoteResponse(a.logger, resp.GetQuote(), token)
}
func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for quotes", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when quoting payments", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeQuotePaymentsPayload(r)
if err != nil {
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := payload.Validate(); err != nil {
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
intents := make([]*orchestratorv1.PaymentIntent, 0, len(payload.Intents))
for i := range payload.Intents {
intent, err := mapPaymentIntent(&payload.Intents[i])
if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
intents = append(intents, intent)
}
req := &orchestratorv1.QuotePaymentsRequest{
Meta: &orchestratorv1.RequestMeta{
OrganizationRef: orgRef.Hex(),
},
IdempotencyKey: payload.IdempotencyKey,
Intents: intents,
PreviewOnly: payload.PreviewOnly,
}
resp, err := a.client.QuotePayments(ctx, req)
if err != nil {
a.logger.Warn("Failed to quote payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.PaymentQuotesResponse(a.logger, resp, token)
}
func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
defer r.Body.Close()
@@ -80,3 +136,17 @@ func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
}
return payload, nil
}
func decodeQuotePaymentsPayload(r *http.Request) (*srequest.QuotePayments, error) {
defer r.Body.Close()
payload := &srequest.QuotePayments{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

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