123 Commits

Author SHA1 Message Date
69fdbf4e95 Merge pull request 'Fixed payment information form in address recipient book and fixed some headers' (#163) from SEND016 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: #163
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-25 13:18:37 +00:00
Arseni
be10839e3a Fixed payment information form in address recipient book and fixed some headers 2025-12-25 15:10:20 +03:00
d530af43a1 Merge pull request 'EVM, ARB, ETH gas top up policies + tron config change' (#162) from tron-161 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/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #162
2025-12-25 11:54:02 +00:00
Stephan D
aa673fb26d EVM, ARB, ETH gas top up policies + tron config change 2025-12-25 12:52:34 +01:00
d978e24a9d Merge pull request 'Gas topup limits' (#160) from tron-159 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/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
Reviewed-on: #160
2025-12-25 11:29:45 +00:00
Stephan D
31d93e5113 Gas topup limits 2025-12-25 12:26:24 +01:00
f02f3449f3 Merge pull request 'gas tanking before transaction' (#158) from tron-157 into main
All checks were 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/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff 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
Reviewed-on: #158
2025-12-25 10:30:05 +00:00
Stephan D
d46822b9bb gas tanking before transaction 2025-12-25 11:25:13 +01:00
0505b2314e Merge pull request 'quotation provider now uses payment methods as source for quotation' (#156) from flow-153 into main
All checks were 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/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #156
2025-12-24 19:40:15 +00:00
Stephan D
407e704352 quotation provider now uses payment methods as source for quotation 2025-12-24 20:39:17 +01:00
4251dfb2c6 Merge pull request 'added wallet source to quotation preparation' (#152) from wallet-151 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/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #152
2025-12-24 19:00:24 +00:00
Stephan D
e0820c47c2 added wallet source to quotation preparation 2025-12-24 19:59:50 +01:00
68b82cbca2 Merge pull request 'chain network name display fixed' (#147) from wallet-147 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/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #147
2025-12-24 17:18:13 +00:00
Stephan D
9e6d530385 chain network name display fixed 2025-12-24 18:17:35 +01:00
5836292adb Merge pull request 'Added Last Name display and made it editable' (#145) from SEND015 into main
All checks were 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/bff Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification 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/payments_orchestrator Pipeline was successful
Reviewed-on: #145
2025-12-24 16:08:40 +00:00
0c6229331f Merge pull request 'Password field checks for match with old password from db and check so that new password feild matches with the confirm password field' (#143) from SEND013 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db 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
Reviewed-on: #143
2025-12-24 16:07:58 +00:00
8cb6a64f2b Merge pull request 'Got rid of bools in 2fa provider' (#144) from SEND014 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/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/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #144
2025-12-24 16:07:07 +00:00
Arseni
4453dab366 Added Last Name display and made it editable 2025-12-24 18:48:33 +03:00
Arseni
512f25f74f Got rid of bools in 2fa provider 2025-12-24 16:26:22 +03:00
Arseni
43020f3eb6 Password field checks for match with old password from db and check so that new password feild matches with the confirm password field 2025-12-24 16:18:52 +03:00
964e90767d Merge pull request 'tron refactoring' (#142) from tron-150 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/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator 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
Reviewed-on: #142
2025-12-24 12:20:46 +00:00
Stephan D
03cd2f4784 tron refactoring 2025-12-24 13:20:25 +01:00
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
97f71d125e Merge pull request 'removed deprecation warnings' (#71) from deprecation-70 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/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #71
2025-12-11 10:23:12 +00:00
Stephan D
8db2f3926c deprecation fixed 2025-12-11 11:22:51 +01:00
Stephan D
2b68b59eca removed deprecation warnings 2025-12-11 11:11:54 +01:00
d07e64fc4f Merge pull request 'fix fx/oracle compilation' (#68) from bug-66 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 was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #68
2025-12-11 09:36:50 +00:00
Stephan D
8e40e6247b fix fx/oracle compilation 2025-12-11 10:36:31 +01:00
779cb0ead9 Merge pull request 'fix' (#65) from bug-64 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 failed
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: #65
2025-12-11 00:45:07 +00:00
Stephan D
2e0057f839 fix 2025-12-11 01:44:40 +01:00
25080ae168 Merge pull request 'fix' (#63) from bug-62 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
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_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/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #63
2025-12-11 00:31:19 +00:00
Stephan D
e6b001dc61 fix 2025-12-11 01:30:28 +01:00
97d1470515 Merge pull request '+ quotation provider' (#60) from quote-front-59 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: #60
2025-12-11 00:13:35 +00:00
Stephan D
a4481fb63d + quotation provider 2025-12-11 01:13:13 +01:00
bdf766075e Merge pull request 'payment rails' (#58) from payment-service-52 into main
Some checks failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #58
2025-12-10 17:50:38 +00:00
Stephan D
47899e25d4 payment rails 2025-12-10 18:40:55 +01:00
4ec934c96b Merge pull request 'fixed CORS wildcard' (#55) from CORS-#54 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/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #55
2025-12-09 19:00:01 +00:00
Stephan D
19df740550 fixed CORS wildcard 2025-12-09 19:59:33 +01:00
1079ad7d0a Merge pull request 'Organizations now load only once' (#38) from SEND002 into main
Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/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 failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #38
2025-12-09 18:53:13 +00:00
Arseni
660f689a7a Current org now sets after list gets to the state of the provider 2025-12-09 16:15:36 +03:00
Arseni
8115abb569 Organizations now load only once 2025-12-08 19:10:33 +03:00
382 changed files with 14788 additions and 2876 deletions

4
.gitignore vendored
View File

@@ -8,4 +8,6 @@ devtools_options.yaml
untranslated.txt untranslated.txt
generate_protos.sh generate_protos.sh
update_dep.sh update_dep.sh
.vscode/ .vscode/
.gocache/
.cache/

View File

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

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

View File

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

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 { if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required") return nil, nil, merrors.InvalidArgument("fees: plans store is required")
} }
// Try org-specific first if provided. // Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() { if orgRef != nil && !orgRef.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil { if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) { } else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex())) r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
return nil, nil, selErr return nil, nil, selErr
} }
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex())) r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex())) r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
return nil, nil, err return nil, nil, err
} }
} }

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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) application, err := app.New(logger, *configFile)
if err != nil { if err != nil {
logger.Fatal("Failed to initialise application", zap.Error(err)) logger.Error("Failed to initialise application", zap.Error(err))
} } else {
if err := application.Run(ctx); err != nil {
if err := application.Run(ctx); err != nil { if errors.Is(err, context.Canceled) {
if errors.Is(err, context.Canceled) { logger.Info("FX ingestor stopped")
logger.Info("FX ingestor stopped") return
return }
logger.Error("Ingestor terminated with error", zap.Error(err))
} }
logger.Fatal("Ingestor terminated with error", zap.Error(err))
} }
logger.Info("FX ingestor stopped") logger.Info("FX ingestor stopped")

View File

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

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

View File

@@ -8,9 +8,9 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
) )
@@ -138,11 +138,11 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
Pair: qc.pair.Pair, Pair: qc.pair.Pair,
Side: qc.sideModel, Side: qc.sideModel,
Price: formatRat(qc.priceRounded, qc.priceScale), Price: formatRat(qc.priceRounded, qc.priceScale),
BaseAmount: model.Money{ BaseAmount: smodel.Money{
Currency: qc.pair.Pair.Base, Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale), Amount: formatRat(qc.baseRounded, qc.baseScale),
}, },
QuoteAmount: model.Money{ QuoteAmount: smodel.Money{
Currency: qc.pair.Pair.Quote, Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale), Amount: formatRat(qc.quoteRounded, qc.quoteScale),
}, },
@@ -170,10 +170,13 @@ func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
} }
trace := meta.GetTrace() trace := meta.GetTrace()
qm := &model.QuoteMeta{ qm := &model.QuoteMeta{
RequestRef: deriveRequestRef(meta, trace), TenantRef: meta.GetTenantRef(),
TenantRef: meta.GetTenantRef(), }
TraceRef: deriveTraceRef(meta, trace),
IdempotencyKey: deriveIdempotencyKey(meta, trace), if trace != nil {
qm.RequestRef = trace.GetRequestRef()
qm.TraceRef = trace.GetTraceRef()
qm.IdempotencyKey = trace.GetIdempotencyKey()
} }
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" { if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil { if objID, err := primitive.ObjectIDFromHex(org); err == nil {
@@ -200,24 +203,3 @@ func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
} }
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
} }
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetRequestRef() != "" {
return trace.GetRequestRef()
}
return meta.GetRequestRef()
}
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetTraceRef() != "" {
return trace.GetTraceRef()
}
return meta.GetTraceRef()
}
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetIdempotencyKey() != "" {
return trace.GetIdempotencyKey()
}
return meta.GetIdempotencyKey()
}

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

@@ -3,7 +3,6 @@ package oracle
import ( import (
"math/big" "math/big"
"strings" "strings"
"time"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/decimal"
@@ -61,7 +60,3 @@ func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
return ratFromString(priceStr) return ratFromString(priceStr)
} }
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

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

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
@@ -381,8 +382,8 @@ func TestServiceValidateQuote(t *testing.T) {
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}, Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote, Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10", Price: "1.10",
BaseAmount: model.Money{Currency: "USD", Amount: "100"}, BaseAmount: smodel.Money{Currency: "USD", Amount: "100"},
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"}, QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(), ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued, Status: model.QuoteStatusIssued,
}, nil }, nil

View File

@@ -4,9 +4,9 @@ import (
"strings" "strings"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
) )
@@ -15,18 +15,11 @@ func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
if meta == nil { if meta == nil {
return resp return resp
} }
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace() trace := meta.GetTrace()
if trace == nil { if trace != nil {
trace = &tracev1.TraceContext{ resp.Trace = trace
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
} }
resp.Trace = trace
return resp return resp
} }
@@ -49,7 +42,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
} }
} }
func moneyModelToProto(m *model.Money) *moneyv1.Money { func moneyModelToProto(m *smodel.Money) *moneyv1.Money {
if m == nil { if m == nil {
return nil return nil
} }

View File

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

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

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
) )
// Quote represents a firm or indicative quote persisted by the oracle. // Quote represents a firm or indicative quote persisted by the oracle.
@@ -16,8 +17,8 @@ type Quote struct {
Pair CurrencyPair `bson:"pair" json:"pair"` Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"` Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"` Price string `bson:"price" json:"price"`
BaseAmount Money `bson:"baseAmount" json:"baseAmount"` BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"` QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"` AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"` ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`

View File

@@ -51,12 +51,6 @@ type CurrencyPair struct {
Quote string `bson:"quote" json:"quote"` Quote string `bson:"quote" json:"quote"`
} }
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}
// QuoteMeta carries request-scoped metadata associated with a quote. // QuoteMeta carries request-scoped metadata associated with a quote.
type QuoteMeta struct { type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -24,6 +24,8 @@ type Client interface {
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
Close() error Close() error
} }
@@ -36,6 +38,8 @@ type grpcGatewayClient interface {
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error) GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error) ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error) EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
} }
type chainGatewayClient struct { type chainGatewayClient struct {
@@ -139,6 +143,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain
return c.client.EstimateTransferFee(ctx, req) return c.client.EstimateTransferFee(ctx, req)
} }
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ComputeGasTopUp(ctx, req)
}
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.EnsureGasTopUp(ctx, req)
}
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.cfg.CallTimeout timeout := c.cfg.CallTimeout
if timeout <= 0 { if timeout <= 0 {

View File

@@ -16,6 +16,8 @@ type Fake struct {
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
CloseFn func() error CloseFn func() error
} }
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
return &chainv1.EstimateTransferFeeResponse{}, nil return &chainv1.EstimateTransferFeeResponse{}, nil
} }
func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
if f.ComputeGasTopUpFn != nil {
return f.ComputeGasTopUpFn(ctx, req)
}
return &chainv1.ComputeGasTopUpResponse{}, nil
}
func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
if f.EnsureGasTopUpFn != nil {
return f.EnsureGasTopUpFn(ctx, req)
}
return &chainv1.EnsureGasTopUpResponse{}, nil
}
func (f *Fake) Close() error { func (f *Fake) Close() error {
if f.CloseFn != nil { if f.CloseFn != nil {
return f.CloseFn() return f.CloseFn()

View File

@@ -34,16 +34,23 @@ messaging:
reconnect_wait: 5 reconnect_wait: 5
chains: chains:
- name: arbitrum_one - name: tron_mainnet
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL chain_id: 728126428 # 0x2b6653dc
native_token: TRX
rpc_url_env: CHAIN_GATEWAY_RPC_URL
gas_topup_policy:
buffer_percent: 0.10
min_native_balance_trx: 10
rounding_unit_trx: 1
max_topup_trx: 100
tokens: tokens:
- symbol: USDC
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
- symbol: USDT - symbol: USDT
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
- symbol: USDC
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
service_wallet: service_wallet:
chain: arbitrum_one chain: tron_mainnet
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
@@ -58,3 +65,4 @@ key_management:
cache: cache:
wallet_balance_ttl_seconds: 120 wallet_balance_ttl_seconds: 120
rpc_request_timeout_seconds: 15

View File

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

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

View File

@@ -2,14 +2,18 @@ package serverimp
import ( import (
"context" "context"
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault" vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway" gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo" gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
@@ -30,6 +34,8 @@ type Imp struct {
config *config config *config
app *grpcapp.App[storage.Repository] app *grpcapp.App[storage.Repository]
rpcClients *rpcclient.Clients
} }
type config struct { type config struct {
@@ -41,11 +47,12 @@ type config struct {
} }
type chainConfig struct { type chainConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
RPCURLEnv string `yaml:"rpc_url_env"` RPCURLEnv string `yaml:"rpc_url_env"`
ChainID uint64 `yaml:"chain_id"` ChainID uint64 `yaml:"chain_id"`
NativeToken string `yaml:"native_token"` NativeToken string `yaml:"native_token"`
Tokens []tokenConfig `yaml:"tokens"` Tokens []tokenConfig `yaml:"tokens"`
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
} }
type serviceWalletConfig struct { type serviceWalletConfig struct {
@@ -61,6 +68,19 @@ type tokenConfig struct {
ContractEnv string `yaml:"contract_env"` ContractEnv string `yaml:"contract_env"`
} }
type gasTopUpPolicyConfig struct {
gasTopUpRuleConfig `yaml:",inline"`
Native *gasTopUpRuleConfig `yaml:"native"`
Contract *gasTopUpRuleConfig `yaml:"contract"`
}
type gasTopUpRuleConfig struct {
BufferPercent float64 `yaml:"buffer_percent"`
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
}
// Create initialises the chain gateway server implementation. // Create initialises the chain gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{ return &Imp{
@@ -84,6 +104,9 @@ func (i *Imp) Shutdown() {
defer cancel() defer cancel()
i.app.Shutdown(ctx) i.app.Shutdown(ctx)
if i.rpcClients != nil {
i.rpcClients.Close()
}
} }
func (i *Imp) Start() error { func (i *Imp) Start() error {
@@ -98,20 +121,34 @@ func (i *Imp) Start() error {
} }
cl := i.logger.Named("config") cl := i.logger.Named("config")
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains) networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
if err != nil {
i.logger.Error("invalid chain network configuration", zap.Error(err))
return err
}
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
if err != nil {
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
return err
}
i.rpcClients = rpcClients
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet) walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement) keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
if err != nil { if err != nil {
return err return err
} }
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
if err != nil {
return err
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
opts := []gatewayservice.Option{ opts := []gatewayservice.Option{
gatewayservice.WithNetworks(networkConfigs), gatewayservice.WithNetworks(networkConfigs),
gatewayservice.WithServiceWallet(walletConfig), gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager), gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithTransferExecutor(executor), gatewayservice.WithRPCClients(rpcClients),
gatewayservice.WithDriverRegistry(driverRegistry),
gatewayservice.WithSettings(cfg.Settings), gatewayservice.WithSettings(cfg.Settings),
} }
return gatewayservice.NewService(logger, repo, producer, opts...), nil return gatewayservice.NewService(logger, repo, producer, opts...), nil
@@ -157,7 +194,7 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil return cfg, nil
} }
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network { func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
result := make([]gatewayshared.Network, 0, len(chains)) result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains { for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" { if strings.TrimSpace(chain.Name) == "" {
@@ -166,7 +203,8 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
} }
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv)) rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" { if rpcURL == "" {
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv)) logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
} }
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens)) contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
for _, token := range chain.Tokens { for _, token := range chain.Tokens {
@@ -194,15 +232,84 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
}) })
} }
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
if err != nil {
logger.Error("invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
return nil, err
}
result = append(result, gatewayshared.Network{ result = append(result, gatewayshared.Network{
Name: chain.Name, Name: chain.Name,
RPCURL: rpcURL, RPCURL: rpcURL,
ChainID: chain.ChainID, ChainID: chain.ChainID,
NativeToken: chain.NativeToken, NativeToken: chain.NativeToken,
TokenConfigs: contracts, TokenConfigs: contracts,
GasTopUpPolicy: gasPolicy,
}) })
} }
return result return result, nil
}
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
if cfg == nil {
return nil, nil
}
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
if err != nil {
return nil, err
}
if !defaultSet {
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
}
policy := &gatewayshared.GasTopUpPolicy{
Default: defaultRule,
}
if cfg.Native != nil {
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
if err != nil {
return nil, err
}
if set {
policy.Native = &rule
}
}
if cfg.Contract != nil {
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
if err != nil {
return nil, err
}
if set {
policy.Contract = &rule
}
}
return policy, nil
}
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
return gatewayshared.GasTopUpRule{}, false, nil
}
if cfg.BufferPercent < 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
}
if cfg.MinNativeBalanceTRX < 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
}
if cfg.RoundingUnitTRX <= 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
}
if cfg.MaxTopUpTRX <= 0 {
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
}
return gatewayshared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
}, true, nil
} }
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet { func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {

View File

@@ -23,6 +23,8 @@ type Registry struct {
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse] GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse] ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse] EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
} }
type RegistryDeps struct { type RegistryDeps struct {
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")), GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")), ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")), EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
} }
} }

View File

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

View File

@@ -43,8 +43,22 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
deps.Logger.Warn("destination external address missing") deps.Logger.Warn("destination external address missing")
return model.TransferDestination{}, merrors.InvalidArgument("destination is required") return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
} }
if deps.Drivers == nil {
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
}
chainDriver, err := deps.Drivers.Driver(source.Network)
if err != nil {
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
}
normalized, err := chainDriver.NormalizeAddress(external)
if err != nil {
deps.Logger.Warn("invalid external address", zap.Error(err))
return model.TransferDestination{}, err
}
return model.TransferDestination{ return model.TransferDestination{
ExternalAddress: strings.ToLower(external), ExternalAddress: normalized,
Memo: strings.TrimSpace(dest.GetMemo()), Memo: strings.TrimSpace(dest.GetMemo()),
}, nil }, nil
} }

View File

@@ -4,11 +4,12 @@ import (
"context" "context"
"strings" "strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
) )
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) { func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" { if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
wallet, err := deps.Storage.Wallets().Get(ctx, ref) wallet, err := deps.Storage.Wallets().Get(ctx, ref)
if err != nil { if err != nil {
@@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti
if strings.TrimSpace(wallet.DepositAddress) == "" { if strings.TrimSpace(wallet.DepositAddress) == "" {
return "", merrors.Internal("destination wallet missing deposit address") return "", merrors.Internal("destination wallet missing deposit address")
} }
return wallet.DepositAddress, nil return chainDriver.NormalizeAddress(wallet.DepositAddress)
} }
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
return strings.ToLower(addr), nil return chainDriver.NormalizeAddress(addr)
} }
return "", merrors.InvalidArgument("transfer destination address not resolved") return "", merrors.InvalidArgument("transfer destination address not resolved")
} }

View File

@@ -3,22 +3,12 @@ package transfer
import ( import (
"context" "context"
"errors" "errors"
"math/big"
"strings" "strings"
"time"
"github.com/ethereum/go-ethereum" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -63,11 +53,20 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
} }
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks[networkKey] networkCfg, ok := c.deps.Networks.Network(networkKey)
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey)) c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
} }
if c.deps.Drivers == nil {
c.deps.Logger.Warn("chain drivers missing", zap.String("network", networkKey))
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
}
chainDriver, err := c.deps.Drivers.Driver(networkKey)
if err != nil {
c.deps.Logger.Warn("unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet) dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
if err != nil { if err != nil {
@@ -79,170 +78,30 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
destinationAddress, err := destinationAddress(ctx, c.deps, dest) destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
if err != nil { if err != nil {
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err)) c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount) driverDeps := driver.Deps{
Logger: c.deps.Logger,
Registry: c.deps.Networks,
RPCTimeout: c.deps.RPCTimeout,
}
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
if err != nil { if err != nil {
c.deps.Logger.Warn("fee estimation failed", zap.Error(err)) c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
contextLabel := "erc20_transfer"
if strings.TrimSpace(sourceWallet.ContractAddress) == "" {
contextLabel = "native_transfer"
}
resp := &chainv1.EstimateTransferFeeResponse{ resp := &chainv1.EstimateTransferFeeResponse{
NetworkFee: feeMoney, NetworkFee: feeMoney,
EstimationContext: "erc20_transfer", EstimationContext: contextLabel,
} }
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.InvalidArgument("network rpc url not configured")
}
if strings.TrimSpace(wallet.ContractAddress) == "" {
return nil, merrors.NotImplemented("native token transfers not supported")
}
if !common.IsHexAddress(wallet.ContractAddress) {
return nil, merrors.InvalidArgument("invalid token contract address")
}
if !common.IsHexAddress(wallet.DepositAddress) {
return nil, merrors.InvalidArgument("invalid source wallet address")
}
if !common.IsHexAddress(destination) {
return nil, merrors.InvalidArgument("invalid destination address")
}
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
if err != nil {
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(wallet.ContractAddress)
toAddr := common.HexToAddress(destination)
fromAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
logger.Warn("failed to read token decimals", zap.Error(err))
return nil, err
}
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
if err != nil {
return nil, err
}
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
if err != nil {
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
From: fromAddr,
To: &tokenAddr,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
if err != nil {
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
feeDec := decimal.NewFromBigInt(fee, 0)
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name)
}
return &moneyv1.Money{
Currency: currency,
Amount: feeDec.String(),
}, nil
}
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
callData, err := tokenABI.Pack("decimals")
if err != nil {
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
}
msg := ethereum.CallMsg{
To: &token,
Data: callData,
}
output, err := client.CallContract(ctx, msg, nil)
if err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", output)
if err != nil {
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
}
if len(values) == 0 {
return 0, merrors.Internal("decimals call returned no data")
}
decimals, ok := values[0].(uint8)
if !ok {
return 0, merrors.Internal("decimals call returned unexpected type")
}
return decimals, nil
}
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount))
if err != nil {
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
}
if value.IsNegative() {
return nil, merrors.InvalidArgument("amount must be positive")
}
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
scaled := value.Mul(multiplier)
if !scaled.Equal(scaled.Truncate(0)) {
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
}
return scaled.BigInt(), nil
}
const erc20TransferABI = `
[
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{ "name": "_to", "type": "address" },
{ "name": "_value", "type": "uint256" }
],
"name": "transfer",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
}
]`

View File

@@ -0,0 +1,290 @@
package transfer
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
type computeGasTopUpCommand struct {
deps Deps
}
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
return &computeGasTopUpCommand{deps: deps}
}
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" {
c.deps.Logger.Warn("wallet ref missing")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
}
estimatedFee := req.GetEstimatedTotalFee()
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
c.deps.Logger.Warn("estimated fee missing")
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
}
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
if err != nil {
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
TopupAmount: topUp,
CapHit: capHit,
})
}
type ensureGasTopUpCommand struct {
deps Deps
}
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
return &ensureGasTopUpCommand{deps: deps}
}
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
if req == nil {
c.deps.Logger.Warn("nil request")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
c.deps.Logger.Warn("idempotency key missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
c.deps.Logger.Warn("organization ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
}
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" {
c.deps.Logger.Warn("source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
}
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
if targetWalletRef == "" {
c.deps.Logger.Warn("target wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
}
estimatedFee := req.GetEstimatedTotalFee()
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
c.deps.Logger.Warn("estimated fee missing")
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
}
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
if err != nil {
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
}
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
TopupAmount: nil,
CapHit: capHit,
})
}
submitReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: idempotencyKey,
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
},
Amount: topUp,
Metadata: shared.CloneMetadata(req.GetMetadata()),
ClientReference: strings.TrimSpace(req.GetClientReference()),
}
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
submitResp, err := submitResponder(ctx)
if err != nil {
return nil, err
}
return &chainv1.EnsureGasTopUpResponse{
TopupAmount: topUp,
CapHit: capHit,
Transfer: submitResp.GetTransfer(),
}, nil
}
}
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
walletRef = strings.TrimSpace(walletRef)
estimatedFee = shared.CloneMoney(estimatedFee)
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil {
return nil, false, nil, nil, nil, err
}
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
networkCfg, ok := deps.Networks.Network(networkKey)
if !ok {
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
}
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
if err != nil {
return nil, false, nil, nil, nil, err
}
if strings.HasPrefix(networkKey, "tron") {
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
if err != nil {
return nil, false, nil, nil, nil, err
}
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
}
if networkCfg.GasTopUpPolicy != nil {
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
if err != nil {
return nil, false, nil, nil, nil, err
}
return topUp, capHit, nil, nativeBalance, walletModel, nil
}
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
if err != nil {
return nil, false, nil, nil, nil, err
}
return topUp, false, nil, nativeBalance, walletModel, nil
}
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
if walletModel == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
walletDeps := wallet.Deps{
Logger: deps.Logger.Named("wallet"),
Drivers: deps.Drivers,
Networks: deps.Networks,
KeyManager: nil,
Storage: deps.Storage,
Clock: deps.Clock,
BalanceCacheTTL: 0,
RPCTimeout: deps.RPCTimeout,
EnsureRepository: deps.EnsureRepository,
}
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
if err != nil {
return nil, err
}
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("native balance is unavailable")
}
return nativeBalance, nil
}
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("estimated fee is required")
}
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("native balance is required")
}
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
return nil, merrors.InvalidArgument("native balance currency mismatch")
}
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
if err != nil {
return nil, err
}
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
if err != nil {
return nil, err
}
required := estimated.Sub(current)
if !required.IsPositive() {
return nil, nil
}
return &moneyv1.Money{
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
Amount: required.String(),
}, nil
}
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
if logger == nil {
return
}
fields := []zap.Field{
zap.String("wallet_ref", walletRef),
zap.String("estimated_total_fee", amountString(estimatedFee)),
zap.String("current_native_balance", amountString(nativeBalance)),
zap.String("topup_amount", amountString(topUp)),
zap.Bool("cap_hit", capHit),
}
if walletModel != nil {
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
}
if decision != nil {
fields = append(fields,
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
zap.String("required_trx", decision.RequiredTRX.String()),
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
zap.String("topup_trx", decision.TopUpTRX.String()),
zap.String("operation_type", decision.OperationType),
)
}
logger.Info("gas top-up decision", fields...)
}
func amountString(m *moneyv1.Money) string {
if m == nil {
return ""
}
amount := strings.TrimSpace(m.GetAmount())
currency := strings.TrimSpace(m.GetCurrency())
if amount == "" && currency == "" {
return ""
}
if currency == "" {
return amount
}
if amount == "" {
return currency
}
return amount + " " + currency
}

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")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
} }
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks[networkKey] networkCfg, ok := c.deps.Networks.Network(networkKey)
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey)) c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))

View File

@@ -51,7 +51,7 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet) tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
if chainErr != nil { if chainErr != nil {
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef) stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
@@ -74,37 +74,47 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
} }
calculatedAt := c.now() calculatedAt := c.now()
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt) c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{ return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
Balance: onChainBalanceToProto(balance, calculatedAt), Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
}) })
} }
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance { func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
if balance == nil { if balance == nil && native == nil {
return nil return nil
} }
zero := zeroMoney(balance.Currency) currency := ""
if balance != nil {
currency = balance.Currency
}
zero := zeroMoney(currency)
return &chainv1.WalletBalance{ return &chainv1.WalletBalance{
Available: balance, Available: balance,
NativeAvailable: native,
PendingInbound: zero, PendingInbound: zero,
PendingOutbound: zero, PendingOutbound: zero,
CalculatedAt: timestamppb.New(calculatedAt.UTC()), CalculatedAt: timestamppb.New(calculatedAt.UTC()),
} }
} }
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) { func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
if available == nil { if available == nil && nativeAvailable == nil {
return return
} }
record := &model.WalletBalance{ record := &model.WalletBalance{
WalletRef: walletRef, WalletRef: walletRef,
Available: shared.CloneMoney(available), Available: shared.CloneMoney(available),
PendingInbound: zeroMoney(available.Currency), NativeAvailable: shared.CloneMoney(nativeAvailable),
PendingOutbound: zeroMoney(available.Currency),
CalculatedAt: calculatedAt, CalculatedAt: calculatedAt,
} }
currency := ""
if available != nil {
currency = available.Currency
}
record.PendingInbound = zeroMoney(currency)
record.PendingOutbound = zeroMoney(currency)
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil { if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err)) c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
@@ -59,11 +60,20 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain())) c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
} }
networkCfg, ok := c.deps.Networks[chainKey] networkCfg, ok := c.deps.Networks.Network(chainKey)
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey)) c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
} }
if c.deps.Drivers == nil {
c.deps.Logger.Warn("chain drivers missing", zap.String("chain", chainKey))
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
}
chainDriver, err := c.deps.Drivers.Driver(chainKey)
if err != nil {
c.deps.Logger.Warn("unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol())) tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if tokenSymbol == "" { if tokenSymbol == "" {
@@ -72,10 +82,12 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
} }
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress())) contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
if contractAddress == "" { if contractAddress == "" {
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol) if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
if contractAddress == "" { contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey)) if contractAddress == "" {
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain")) c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
}
} }
} }
@@ -94,8 +106,37 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
c.deps.Logger.Warn("key manager returned empty address") c.deps.Logger.Warn("key manager returned empty address")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
} }
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
if err != nil {
c.deps.Logger.Warn("invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
}
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{ wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
WalletRef: walletRef, WalletRef: walletRef,
OrganizationRef: organizationRef, OrganizationRef: organizationRef,
@@ -103,10 +144,13 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
Network: chainKey, Network: chainKey,
TokenSymbol: tokenSymbol, TokenSymbol: tokenSymbol,
ContractAddress: contractAddress, ContractAddress: contractAddress,
DepositAddress: strings.ToLower(keyInfo.Address), DepositAddress: depositAddress,
KeyReference: keyInfo.KeyID, KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive, Status: model.ManagedWalletStatusActive,
Metadata: shared.CloneMetadata(req.GetMetadata()), Metadata: metadata,
}
if description != nil {
wallet.Describable.Description = description
} }
created, err := c.deps.Storage.Wallets().Create(ctx, wallet) created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

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

View File

@@ -2,123 +2,61 @@ package wallet
import ( import (
"context" "context"
"math/big" "fmt"
"strings" "strings"
"time"
"github.com/ethereum/go-ethereum" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
) )
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) { func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))] logger := deps.Logger
rpcURL := strings.TrimSpace(network.RPCURL) if wallet == nil {
if rpcURL == "" { return nil, nil, merrors.InvalidArgument("wallet is required")
return nil, merrors.Internal("network rpc url is not configured")
} }
contract := strings.TrimSpace(wallet.ContractAddress) if deps.Networks == nil {
if contract == "" || !common.IsHexAddress(contract) { return nil, nil, merrors.Internal("rpc clients not initialised")
return nil, merrors.InvalidArgument("invalid contract address")
} }
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) { if deps.Drivers == nil {
return nil, merrors.InvalidArgument("invalid wallet address") return nil, nil, merrors.Internal("chain drivers not configured")
} }
client, err := ethclient.DialContext(ctx, rpcURL) networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
if err != nil { network, ok := deps.Networks.Network(networkKey)
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(contract)
walletAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
return nil, err
}
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
if err != nil {
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
data, err := tokenABI.Pack("decimals")
if err != nil {
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", out)
if err != nil || len(values) == 0 {
return 0, merrors.Internal("failed to unpack decimals")
}
if val, ok := values[0].(uint8); ok {
return val, nil
}
return 0, merrors.Internal("decimals returned unexpected type")
}
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
data, err := tokenABI.Pack("balanceOf", wallet)
if err != nil {
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
values, err := tokenABI.Unpack("balanceOf", out)
if err != nil || len(values) == 0 {
return nil, merrors.Internal("failed to unpack balanceOf")
}
raw, ok := values[0].(*big.Int)
if !ok { if !ok {
return nil, merrors.Internal("balanceOf returned unexpected type") logger.Warn("Requested network is not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
)
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
} }
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
}
const erc20ABIJSON = ` chainDriver, err := deps.Drivers.Driver(networkKey)
[ if err != nil {
{ logger.Warn("Chain driver not configured",
"constant": true, zap.String("wallet_ref", wallet.WalletRef),
"inputs": [], zap.String("network", networkKey),
"name": "decimals", zap.Error(err),
"outputs": [{ "name": "", "type": "uint8" }], )
"payable": false, return nil, nil, merrors.InvalidArgument("unsupported chain")
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [{ "name": "_owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "balance", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
} }
]`
driverDeps := driver.Deps{
Logger: deps.Logger,
Registry: deps.Networks,
KeyManager: deps.KeyManager,
RPCTimeout: deps.RPCTimeout,
}
tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet)
if err != nil {
return nil, nil, err
}
nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet)
if err != nil {
return nil, nil, err
}
return tokenBalance, nativeBalance, nil
}

View File

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

View File

@@ -0,0 +1,173 @@
package arbitrum
import (
"context"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
// Driver implements Arbitrum-specific behavior using the shared EVM logic.
type Driver struct {
logger mlogger.Logger
}
func New(logger mlogger.Logger) *Driver {
return &Driver{logger: logger.Named("arbitrum")}
}
func (d *Driver) Name() string {
return "arbitrum"
}
func (d *Driver) FormatAddress(address string) (string, error) {
d.logger.Debug("format address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address)
if err != nil {
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("normalize address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address)
if err != nil {
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("balance request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil {
d.logger.Warn("balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("native balance request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil {
d.logger.Warn("native balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("native balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
d.logger.Debug("estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("destination", destination),
)
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
if err != nil {
d.logger.Warn("estimate fee failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
d.logger.Debug("submit transfer request",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("destination", destination),
)
driverDeps := deps
driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
if err != nil {
d.logger.Warn("submit transfer failed",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else {
d.logger.Debug("submit transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("tx_hash", txHash),
)
}
return txHash, err
}
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
d.logger.Debug("await confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
if err != nil {
d.logger.Warn("await confirmation failed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Error(err),
)
} else if receipt != nil {
d.logger.Debug("await confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)
}
return receipt, err
}
var _ driver.Driver = (*Driver)(nil)

View File

@@ -0,0 +1,34 @@
package driver
import (
"context"
"time"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
// Deps bundles dependencies shared across chain drivers.
type Deps struct {
Logger mlogger.Logger
Registry *rpcclient.Registry
KeyManager keymanager.Manager
RPCTimeout time.Duration
}
// Driver defines chain-specific behavior for wallet and transfer operations.
type Driver interface {
Name() string
FormatAddress(address string) (string, error)
NormalizeAddress(address string) (string, error)
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
}

View File

@@ -0,0 +1,173 @@
package ethereum
import (
"context"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
// Driver implements Ethereum-specific behavior using the shared EVM logic.
type Driver struct {
logger mlogger.Logger
}
func New(logger mlogger.Logger) *Driver {
return &Driver{logger: logger.Named("ethereum")}
}
func (d *Driver) Name() string {
return "ethereum"
}
func (d *Driver) FormatAddress(address string) (string, error) {
d.logger.Debug("format address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address)
if err != nil {
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("normalize address", zap.String("address", address))
normalized, err := evm.NormalizeAddress(address)
if err != nil {
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("balance request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil {
d.logger.Warn("balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("native balance request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
if err != nil {
d.logger.Warn("native balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("native balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
d.logger.Debug("estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("destination", destination),
)
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
if err != nil {
d.logger.Warn("estimate fee failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
d.logger.Debug("submit transfer request",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("destination", destination),
)
driverDeps := deps
driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
if err != nil {
d.logger.Warn("submit transfer failed",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.Error(err),
)
} else {
d.logger.Debug("submit transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("tx_hash", txHash),
)
}
return txHash, err
}
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
d.logger.Debug("await confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
if err != nil {
d.logger.Warn("await confirmation failed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Error(err),
)
} else if receipt != nil {
d.logger.Debug("await confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)
}
return receipt, err
}
var _ driver.Driver = (*Driver)(nil)

View File

@@ -0,0 +1,680 @@
package evm
import (
"context"
"errors"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/rpc"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
var (
erc20ABI abi.ABI
)
func init() {
var err error
erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
panic("evm driver: failed to parse erc20 abi: " + err.Error())
}
}
const erc20ABIJSON = `
[
{
"constant": false,
"inputs": [
{ "name": "_to", "type": "address" },
{ "name": "_value", "type": "uint256" }
],
"name": "transfer",
"outputs": [{ "name": "", "type": "bool" }],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]`
// NormalizeAddress validates and normalizes EVM hex addresses.
func NormalizeAddress(address string) (string, error) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", merrors.InvalidArgument("address is required")
}
if !common.IsHexAddress(trimmed) {
return "", merrors.InvalidArgument("invalid hex address")
}
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
}
func nativeCurrency(network shared.Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name)
}
return currency
}
func parseBaseUnitAmount(amount string) (*big.Int, error) {
trimmed := strings.TrimSpace(amount)
if trimmed == "" {
return nil, merrors.InvalidArgument("amount is required")
}
value, ok := new(big.Int).SetString(trimmed, 10)
if !ok {
return nil, merrors.InvalidArgument("invalid amount")
}
if value.Sign() < 0 {
return nil, merrors.InvalidArgument("amount must be non-negative")
}
return value, nil
}
// Balance fetches ERC20 token balance for the provided address.
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
logger := deps.Logger
registry := deps.Registry
if registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
normalizedAddress, err := NormalizeAddress(address)
if err != nil {
return nil, err
}
rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
zap.String("wallet_address", normalizedAddress),
}
if rpcURL == "" {
logger.Warn("Network rpc url is not configured", logFields...)
return nil, merrors.Internal("network rpc url is not configured")
}
contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" {
logger.Debug("Native balance requested", logFields...)
return NativeBalance(ctx, deps, network, wallet, normalizedAddress)
}
if !common.IsHexAddress(contract) {
logger.Warn("Invalid contract address for balance fetch", logFields...)
return nil, merrors.InvalidArgument("invalid contract address")
}
logger.Info("Fetching on-chain wallet balance", logFields...)
rpcClient, err := registry.RPCClient(network.Name)
if err != nil {
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
return nil, err
}
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
logger.Debug("Calling token decimals", logFields...)
decimals, err := readDecimals(timeoutCtx, rpcClient, contract)
if err != nil {
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
return nil, err
}
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, normalizedAddress)
if err != nil {
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
logger.Info("On-chain wallet balance fetched",
append(logFields,
zap.Uint8("decimals", decimals),
zap.String("balance_raw", bal.String()),
zap.String("balance", dec.String()),
)...,
)
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
// NativeBalance fetches native token balance for the provided address.
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
logger := deps.Logger
registry := deps.Registry
if registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
normalizedAddress, err := NormalizeAddress(address)
if err != nil {
return nil, err
}
rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
zap.String("wallet_address", normalizedAddress),
}
if rpcURL == "" {
logger.Warn("Network rpc url is not configured", logFields...)
return nil, merrors.Internal("network rpc url is not configured")
}
client, err := registry.Client(network.Name)
if err != nil {
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
return nil, err
}
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil)
if err != nil {
logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...)
return nil, err
}
logger.Info("On-chain native balance fetched",
append(logFields,
zap.String("balance_raw", bal.String()),
)...,
)
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: bal.String(),
}, nil
}
// EstimateFee estimates ERC20 transfer fees for the given parameters.
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
logger := deps.Logger
registry := deps.Registry
if registry == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
if amount == nil {
return nil, merrors.InvalidArgument("amount is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.InvalidArgument("network rpc url not configured")
}
if _, err := NormalizeAddress(fromAddress); err != nil {
return nil, merrors.InvalidArgument("invalid source wallet address")
}
if _, err := NormalizeAddress(destination); err != nil {
return nil, merrors.InvalidArgument("invalid destination address")
}
client, err := registry.Client(network.Name)
if err != nil {
return nil, err
}
rpcClient, err := registry.RPCClient(network.Name)
if err != nil {
return nil, err
}
timeout := deps.RPCTimeout
if timeout <= 0 {
timeout = 15 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
contract := strings.TrimSpace(wallet.ContractAddress)
toAddr := common.HexToAddress(destination)
fromAddr := common.HexToAddress(fromAddress)
if contract == "" {
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
if err != nil {
return nil, err
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
From: fromAddr,
To: &toAddr,
GasPrice: gasPrice,
Value: amountBase,
}
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
if err != nil {
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
feeDec := decimal.NewFromBigInt(fee, 0)
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: feeDec.String(),
}, nil
}
if !common.IsHexAddress(contract) {
return nil, merrors.InvalidArgument("invalid token contract address")
}
tokenAddr := common.HexToAddress(contract)
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
if err != nil {
logger.Warn("Failed to read token decimals", zap.Error(err))
return nil, err
}
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
if err != nil {
return nil, err
}
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
if err != nil {
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
}
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
if err != nil {
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
}
callMsg := ethereum.CallMsg{
From: fromAddr,
To: &tokenAddr,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
if err != nil {
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
}
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
feeDec := decimal.NewFromBigInt(fee, 0)
return &moneyv1.Money{
Currency: nativeCurrency(network),
Amount: feeDec.String(),
}, nil
}
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
logger := deps.Logger
registry := deps.Registry
if deps.KeyManager == nil {
logger.Warn("Key manager not configured")
return "", executorInternal("key manager is not configured", nil)
}
if registry == nil {
return "", executorInternal("rpc clients not initialised", nil)
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
logger.Warn("Network rpc url missing", zap.String("network", network.Name))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
logger.Warn("Transfer context missing")
return "", executorInvalid("transfer context missing")
}
if strings.TrimSpace(source.KeyReference) == "" {
logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference")
}
if _, err := NormalizeAddress(fromAddress); err != nil {
logger.Warn("Invalid source wallet address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("invalid source wallet address")
}
if _, err := NormalizeAddress(destination); err != nil {
logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destination))
return "", executorInvalid("invalid destination address " + destination)
}
logger.Info("submitting transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name),
zap.String("destination", strings.ToLower(destination)),
)
client, err := registry.Client(network.Name)
if err != nil {
logger.Warn("Failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
return "", err
}
rpcClient, err := registry.RPCClient(network.Name)
if err != nil {
logger.Warn("failed to initialise rpc client", zap.String("network", network.Name))
return "", err
}
sourceAddress := common.HexToAddress(fromAddress)
destinationAddr := common.HexToAddress(destination)
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
if err != nil {
logger.Warn("Failed to fetch nonce", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
)
return "", executorInternal("failed to fetch nonce", err)
}
gasPrice, err := client.SuggestGasPrice(ctx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
)
return "", executorInternal("failed to suggest gas price", err)
}
chainID := new(big.Int).SetUint64(network.ChainID)
contract := strings.TrimSpace(transfer.ContractAddress)
amount := transfer.NetAmount
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
return "", executorInvalid("transfer missing net amount")
}
var tx *types.Transaction
if contract == "" {
amountInt, err := parseBaseUnitAmount(amount.Amount)
if err != nil {
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
return "", err
}
callMsg := ethereum.CallMsg{
From: sourceAddress,
To: &destinationAddr,
GasPrice: gasPrice,
Value: amountInt,
}
gasLimit, err := client.EstimateGas(ctx, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to estimate gas", err)
}
tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil)
} else {
if !common.IsHexAddress(contract) {
logger.Warn("Invalid token contract address",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", contract),
)
return "", executorInvalid("invalid token contract address " + contract)
}
tokenAddress := common.HexToAddress(contract)
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
if err != nil {
logger.Warn("Failed to read token decimals", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", contract),
)
return "", err
}
amountInt, err := toBaseUnits(amount.Amount, decimals)
if err != nil {
logger.Warn("Failed to convert amount to base units", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("amount", amount.Amount),
)
return "", err
}
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
if err != nil {
logger.Warn("Failed to encode transfer call", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to encode transfer call", err)
}
callMsg := ethereum.CallMsg{
From: sourceAddress,
To: &tokenAddress,
GasPrice: gasPrice,
Data: input,
}
gasLimit, err := client.EstimateGas(ctx, callMsg)
if err != nil {
logger.Warn("Failed to estimate gas", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to estimate gas", err)
}
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
}
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
if err != nil {
logger.Warn("Failed to sign transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("wallet_ref", source.WalletRef),
)
return "", err
}
if err := client.SendTransaction(ctx, signedTx); err != nil {
logger.Warn("Failed to send transaction", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
)
return "", executorInternal("failed to send transaction", err)
}
txHash := signedTx.Hash().Hex()
logger.Info("Transaction submitted",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
return txHash, nil
}
// AwaitConfirmation waits for the transaction receipt.
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
logger := deps.Logger
registry := deps.Registry
if strings.TrimSpace(txHash) == "" {
logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
return nil, executorInvalid("tx hash is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
logger.Warn("Network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
return nil, executorInvalid("network rpc url is not configured")
}
if registry == nil {
return nil, executorInternal("rpc clients not initialised", nil)
}
client, err := registry.Client(network.Name)
if err != nil {
return nil, err
}
hash := common.HexToHash(txHash)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
receipt, err := client.TransactionReceipt(ctx, hash)
if err != nil {
if errors.Is(err, ethereum.NotFound) {
select {
case <-ticker.C:
logger.Debug("Transaction not yet mined",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
continue
case <-ctx.Done():
logger.Warn("Context cancelled while awaiting confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
return nil, ctx.Err()
}
}
logger.Warn("Failed to fetch transaction receipt",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Error(err),
)
return nil, executorInternal("failed to fetch transaction receipt", err)
}
logger.Info("Transaction confirmed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)
return receipt, nil
}
}
func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(common.HexToAddress(token).Hex()),
"data": "0x313ce567",
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, merrors.Internal("decimals decode failed: " + err.Error())
}
return val, nil
}
func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) {
tokenAddr := common.HexToAddress(token)
walletAddr := common.HexToAddress(wallet)
addr := strings.TrimPrefix(walletAddr.Hex(), "0x")
if len(addr) < 64 {
addr = strings.Repeat("0", 64-len(addr)) + addr
}
call := map[string]string{
"to": strings.ToLower(tokenAddr.Hex()),
"data": "0x70a08231" + addr,
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
bigVal, err := shared.DecodeHexBig(hexResp)
if err != nil {
return nil, merrors.Internal("balanceOf decode failed: " + err.Error())
}
return bigVal, nil
}
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
call := map[string]string{
"to": strings.ToLower(token.Hex()),
"data": "0x313ce567",
}
var hexResp string
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
return 0, executorInternal("decimals call failed", err)
}
val, err := shared.DecodeHexUint8(hexResp)
if err != nil {
return 0, executorInternal("decimals decode failed", err)
}
return val, nil
}
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount))
if err != nil {
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
}
if value.IsNegative() {
return nil, merrors.InvalidArgument("amount must be positive")
}
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
scaled := value.Mul(multiplier)
if !scaled.Equal(scaled.Truncate(0)) {
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
}
return scaled.BigInt(), nil
}
func executorInvalid(msg string) error {
return merrors.InvalidArgument("executor: " + msg)
}
func executorInternal(msg string, err error) error {
if err != nil {
msg = msg + ": " + err.Error()
}
return merrors.Internal("executor: " + msg)
}

View File

@@ -0,0 +1,108 @@
package evm
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000)
// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount.
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) {
if wallet == nil {
return nil, false, merrors.InvalidArgument("wallet is required")
}
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
return nil, false, merrors.InvalidArgument("estimated fee is required")
}
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
return nil, false, merrors.InvalidArgument("current native balance is required")
}
if network.GasTopUpPolicy == nil {
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
}
nativeCurrency := strings.TrimSpace(network.NativeToken)
if nativeCurrency == "" {
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
}
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
}
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
}
estimatedNative, err := evmToNative(estimatedFee)
if err != nil {
return nil, false, err
}
currentNative, err := evmToNative(currentBalance)
if err != nil {
return nil, false, err
}
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
rule, ok := network.GasTopUpPolicy.Rule(isContract)
if !ok {
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
}
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
}
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0")
}
required := estimatedNative.Sub(currentNative)
if required.IsNegative() {
required = decimal.Zero
}
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative)
if minBalanceTopUp.IsNegative() {
minBalanceTopUp = decimal.Zero
}
rawTopUp := bufferedRequired
if minBalanceTopUp.GreaterThan(rawTopUp) {
rawTopUp = minBalanceTopUp
}
roundedTopUp := decimal.Zero
if rawTopUp.IsPositive() {
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
}
topUp := roundedTopUp
capHit := false
if topUp.GreaterThan(rule.MaxTopUp) {
topUp = rule.MaxTopUp
capHit = true
}
if !topUp.IsPositive() {
return nil, capHit, nil
}
baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0)
return &moneyv1.Money{
Currency: strings.ToUpper(nativeCurrency),
Amount: baseUnits.StringFixed(0),
}, capHit, nil
}
func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
if err != nil {
return decimal.Zero, err
}
return value.Div(evmBaseUnitFactor), nil
}

View File

@@ -0,0 +1,146 @@
package evm
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30"))
require.NoError(t, err)
require.Nil(t, topUp)
require.False(t, capHit)
}
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "46000000000000000000", topUp.GetAmount())
require.Equal(t, "ETH", topUp.GetCurrency())
}
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "19000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := ethNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "2000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_CapHit(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(10),
},
}
network := ethNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.True(t, capHit)
require.Equal(t, "10000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
network := ethNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "15000000000000000000", topUp.GetAmount())
}
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.1),
MinNativeBalance: decimal.NewFromFloat(10),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
Contract: &shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.5),
MinNativeBalance: decimal.NewFromFloat(5),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := ethNetwork(&policy)
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.False(t, capHit)
require.Equal(t, "15000000000000000000", topUp.GetAmount())
}
func defaultPolicy() *shared.GasTopUpPolicy {
return &shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.15),
MinNativeBalance: decimal.NewFromFloat(20),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(500),
},
}
}
func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network {
return shared.Network{
Name: "ethereum_mainnet",
NativeToken: "ETH",
GasTopUpPolicy: policy,
}
}
func ethMoney(eth string) *moneyv1.Money {
value, _ := decimal.NewFromString(eth)
baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0)
return &moneyv1.Money{
Currency: "ETH",
Amount: baseUnits.StringFixed(0),
}
}

View File

@@ -0,0 +1,193 @@
package tron
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"github.com/tech/sendico/pkg/merrors"
)
const tronHexPrefix = "0x"
var base58Alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
func normalizeAddress(address string) (string, error) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", merrors.InvalidArgument("address is required")
}
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
return hexToBase58(trimmed)
}
decoded, err := base58Decode(trimmed)
if err != nil {
return "", err
}
if err := validateChecksum(decoded); err != nil {
return "", err
}
return base58Encode(decoded), nil
}
func rpcAddress(address string) (string, error) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", merrors.InvalidArgument("address is required")
}
if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) {
return normalizeHexRPC(trimmed)
}
return base58ToHex(trimmed)
}
func hexToBase58(address string) (string, error) {
bytesAddr, err := parseHexAddress(address)
if err != nil {
return "", err
}
payload := append(bytesAddr, checksum(bytesAddr)...)
return base58Encode(payload), nil
}
func base58ToHex(address string) (string, error) {
decoded, err := base58Decode(address)
if err != nil {
return "", err
}
if err := validateChecksum(decoded); err != nil {
return "", err
}
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
}
func parseHexAddress(address string) ([]byte, error) {
trimmed := strings.TrimPrefix(strings.TrimSpace(address), tronHexPrefix)
if trimmed == "" {
return nil, merrors.InvalidArgument("address is required")
}
if len(trimmed)%2 == 1 {
trimmed = "0" + trimmed
}
decoded, err := hex.DecodeString(trimmed)
if err != nil {
return nil, merrors.InvalidArgument("invalid hex address")
}
switch len(decoded) {
case 20:
prefixed := make([]byte, 21)
prefixed[0] = 0x41
copy(prefixed[1:], decoded)
return prefixed, nil
case 21:
if decoded[0] != 0x41 {
return nil, merrors.InvalidArgument("invalid tron address prefix")
}
return decoded, nil
default:
return nil, merrors.InvalidArgument(fmt.Sprintf("invalid tron address length %d", len(decoded)))
}
}
func normalizeHexRPC(address string) (string, error) {
decoded, err := parseHexAddress(address)
if err != nil {
return "", err
}
return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil
}
func validateChecksum(decoded []byte) error {
if len(decoded) != 25 {
return merrors.InvalidArgument("invalid tron address length")
}
payload := decoded[:21]
expected := checksum(payload)
if !bytes.Equal(expected, decoded[21:]) {
return merrors.InvalidArgument("invalid tron address checksum")
}
if payload[0] != 0x41 {
return merrors.InvalidArgument("invalid tron address prefix")
}
return nil
}
func checksum(payload []byte) []byte {
first := sha256.Sum256(payload)
second := sha256.Sum256(first[:])
return second[:4]
}
func base58Encode(input []byte) string {
if len(input) == 0 {
return ""
}
x := new(big.Int).SetBytes(input)
base := big.NewInt(58)
zero := big.NewInt(0)
mod := new(big.Int)
encoded := make([]byte, 0, len(input))
for x.Cmp(zero) > 0 {
x.DivMod(x, base, mod)
encoded = append(encoded, base58Alphabet[mod.Int64()])
}
for _, b := range input {
if b != 0 {
break
}
encoded = append(encoded, base58Alphabet[0])
}
reverse(encoded)
return string(encoded)
}
func base58Decode(input string) ([]byte, error) {
result := big.NewInt(0)
base := big.NewInt(58)
for i := 0; i < len(input); i++ {
idx := bytes.IndexByte(base58Alphabet, input[i])
if idx < 0 {
return nil, merrors.InvalidArgument("invalid base58 address")
}
result.Mul(result, base)
result.Add(result, big.NewInt(int64(idx)))
}
decoded := result.Bytes()
zeroCount := 0
for zeroCount < len(input) && input[zeroCount] == base58Alphabet[0] {
zeroCount++
}
if zeroCount > 0 {
decoded = append(make([]byte, zeroCount), decoded...)
}
return decoded, nil
}
func reverse(data []byte) {
for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
data[i], data[j] = data[j], data[i]
}
}
func isHexString(value string) bool {
trimmed := strings.TrimPrefix(strings.TrimSpace(value), tronHexPrefix)
if trimmed == "" {
return false
}
for _, r := range trimmed {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}

View File

@@ -0,0 +1,223 @@
package tron
import (
"context"
"github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
// Driver implements Tron-specific behavior, including address conversion.
type Driver struct {
logger mlogger.Logger
}
func New(logger mlogger.Logger) *Driver {
return &Driver{logger: logger.Named("tron")}
}
func (d *Driver) Name() string {
return "tron"
}
func (d *Driver) FormatAddress(address string) (string, error) {
d.logger.Debug("Format address", zap.String("address", address))
normalized, err := normalizeAddress(address)
if err != nil {
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) NormalizeAddress(address string) (string, error) {
d.logger.Debug("Normalize address", zap.String("address", address))
normalized, err := normalizeAddress(address)
if err != nil {
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
}
return normalized, err
}
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
rpcAddr, err := rpcAddress(wallet.DepositAddress)
if err != nil {
d.logger.Warn("Balance address conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("address", wallet.DepositAddress),
)
return nil, err
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.Balance(ctx, driverDeps, network, wallet, rpcAddr)
if err != nil {
d.logger.Warn("Balance failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
} else if result != nil {
d.logger.Debug("balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
rpcAddr, err := rpcAddress(wallet.DepositAddress)
if err != nil {
d.logger.Warn("Native balance address conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("address", wallet.DepositAddress),
)
return nil, err
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr)
if err != nil {
d.logger.Warn("Native balance failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
} else if result != nil {
d.logger.Debug("native balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("wallet is required")
}
d.logger.Debug("Estimate fee request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("destination", destination),
)
rpcFrom, err := rpcAddress(wallet.DepositAddress)
if err != nil {
d.logger.Warn("Estimate fee address conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("address", wallet.DepositAddress),
)
return nil, err
}
rpcTo, err := rpcAddress(destination)
if err != nil {
d.logger.Warn("Estimate fee destination conversion failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("destination", destination),
)
return nil, err
}
driverDeps := deps
driverDeps.Logger = d.logger
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
if err != nil {
d.logger.Warn("Estimate fee failed", zap.Error(err),
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
}
return result, err
}
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
if source == nil {
return "", merrors.InvalidArgument("source wallet is required")
}
d.logger.Debug("Submit transfer request",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("destination", destination),
)
rpcFrom, err := rpcAddress(source.DepositAddress)
if err != nil {
d.logger.Warn("Submit transfer address conversion failed", zap.Error(err),
zap.String("wallet_ref", source.WalletRef),
zap.String("address", source.DepositAddress),
)
return "", err
}
rpcTo, err := rpcAddress(destination)
if err != nil {
d.logger.Warn("Submit transfer destination conversion failed", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("destination", destination),
)
return "", err
}
driverDeps := deps
driverDeps.Logger = d.logger
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
if err != nil {
d.logger.Warn("submit transfer failed", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
)
} else {
d.logger.Debug("Submit transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("tx_hash", txHash),
)
}
return txHash, err
}
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
d.logger.Debug("Awaiting confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
driverDeps := deps
driverDeps.Logger = d.logger
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
if err != nil {
d.logger.Warn("Awaiting of confirmation failed", zap.Error(err),
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
)
} else if receipt != nil {
d.logger.Debug("Await confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)
}
return receipt, err
}
var _ driver.Driver = (*Driver)(nil)

View File

@@ -0,0 +1,143 @@
package tron
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
type GasTopUpDecision struct {
CurrentBalanceTRX decimal.Decimal
EstimatedFeeTRX decimal.Decimal
RequiredTRX decimal.Decimal
BufferedRequiredTRX decimal.Decimal
MinBalanceTopUpTRX decimal.Decimal
RawTopUpTRX decimal.Decimal
RoundedTopUpTRX decimal.Decimal
TopUpTRX decimal.Decimal
CapHit bool
OperationType string
}
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
decision := GasTopUpDecision{}
if wallet == nil {
return nil, decision, merrors.InvalidArgument("wallet is required")
}
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
return nil, decision, merrors.InvalidArgument("estimated fee is required")
}
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
return nil, decision, merrors.InvalidArgument("current native balance is required")
}
if network.GasTopUpPolicy == nil {
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
}
nativeCurrency := strings.TrimSpace(network.NativeToken)
if nativeCurrency == "" {
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
}
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
}
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
}
estimatedTRX, err := tronToTRX(estimatedFee)
if err != nil {
return nil, decision, err
}
currentTRX, err := tronToTRX(currentBalance)
if err != nil {
return nil, decision, err
}
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
rule, ok := network.GasTopUpPolicy.Rule(isContract)
if !ok {
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
}
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
}
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
}
required := estimatedTRX.Sub(currentTRX)
if required.IsNegative() {
required = decimal.Zero
}
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
if minBalanceTopUp.IsNegative() {
minBalanceTopUp = decimal.Zero
}
rawTopUp := bufferedRequired
if minBalanceTopUp.GreaterThan(rawTopUp) {
rawTopUp = minBalanceTopUp
}
roundedTopUp := decimal.Zero
if rawTopUp.IsPositive() {
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
}
topUp := roundedTopUp
capHit := false
if topUp.GreaterThan(rule.MaxTopUp) {
topUp = rule.MaxTopUp
capHit = true
}
decision = GasTopUpDecision{
CurrentBalanceTRX: currentTRX,
EstimatedFeeTRX: estimatedTRX,
RequiredTRX: required,
BufferedRequiredTRX: bufferedRequired,
MinBalanceTopUpTRX: minBalanceTopUp,
RawTopUpTRX: rawTopUp,
RoundedTopUpTRX: roundedTopUp,
TopUpTRX: topUp,
CapHit: capHit,
OperationType: operationType(isContract),
}
if !topUp.IsPositive() {
return nil, decision, nil
}
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
return &moneyv1.Money{
Currency: strings.ToUpper(nativeCurrency),
Amount: baseUnits.StringFixed(0),
}, decision, nil
}
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
if err != nil {
return decimal.Zero, err
}
return value.Div(tronBaseUnitFactor), nil
}
func operationType(contract bool) string {
if contract {
return "trc20"
}
return "native"
}

View File

@@ -0,0 +1,147 @@
package tron
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
require.NoError(t, err)
require.Nil(t, topUp)
require.True(t, decision.TopUpTRX.IsZero())
}
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "46000000", topUp.GetAmount())
require.Equal(t, "TRX", topUp.GetCurrency())
require.Equal(t, "46", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "19000000", topUp.GetAmount())
require.Equal(t, "19", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := tronNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "2000000", topUp.GetAmount())
require.Equal(t, "2", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_CapHit(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0),
MinNativeBalance: decimal.NewFromFloat(0),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(10),
},
}
network := tronNetwork(&policy)
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "10000000", topUp.GetAmount())
require.True(t, decision.CapHit)
}
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
network := tronNetwork(defaultPolicy())
wallet := &model.ManagedWallet{}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "15000000", topUp.GetAmount())
require.Equal(t, "15", decision.TopUpTRX.String())
}
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
policy := shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.1),
MinNativeBalance: decimal.NewFromFloat(10),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
Contract: &shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.5),
MinNativeBalance: decimal.NewFromFloat(5),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(100),
},
}
network := tronNetwork(&policy)
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
require.NoError(t, err)
require.NotNil(t, topUp)
require.Equal(t, "15000000", topUp.GetAmount())
require.Equal(t, "15", decision.TopUpTRX.String())
require.Equal(t, "trc20", decision.OperationType)
}
func defaultPolicy() *shared.GasTopUpPolicy {
return &shared.GasTopUpPolicy{
Default: shared.GasTopUpRule{
BufferPercent: decimal.NewFromFloat(0.15),
MinNativeBalance: decimal.NewFromFloat(20),
RoundingUnit: decimal.NewFromFloat(1),
MaxTopUp: decimal.NewFromFloat(500),
},
}
}
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
return shared.Network{
Name: "tron_mainnet",
NativeToken: "TRX",
GasTopUpPolicy: policy,
}
}
func tronMoney(trx string) *moneyv1.Money {
value, _ := decimal.NewFromString(trx)
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
return &moneyv1.Money{
Currency: "TRX",
Amount: baseUnits.StringFixed(0),
}
}

View File

@@ -0,0 +1,74 @@
package drivers
import (
"fmt"
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/ethereum"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Registry maps configured network keys to chain drivers.
type Registry struct {
byNetwork map[string]driver.Driver
}
// NewRegistry selects drivers for the configured networks.
func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) {
if logger == nil {
return nil, merrors.InvalidArgument("driver registry: logger is required")
}
result := &Registry{byNetwork: map[string]driver.Driver{}}
for _, network := range networks {
name := strings.ToLower(strings.TrimSpace(network.Name))
if name == "" {
continue
}
chainDriver, err := resolveDriver(logger, name)
if err != nil {
logger.Error("unsupported chain driver", zap.String("network", name), zap.Error(err))
return nil, err
}
result.byNetwork[name] = chainDriver
}
if len(result.byNetwork) == 0 {
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
}
logger.Info("chain drivers configured", zap.Int("count", len(result.byNetwork)))
return result, nil
}
// Driver resolves a driver for the provided network key.
func (r *Registry) Driver(network string) (driver.Driver, error) {
if r == nil || len(r.byNetwork) == 0 {
return nil, merrors.Internal("driver registry is not configured")
}
key := strings.ToLower(strings.TrimSpace(network))
if key == "" {
return nil, merrors.InvalidArgument("network is required")
}
chainDriver, ok := r.byNetwork[key]
if !ok {
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
}
return chainDriver, nil
}
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
switch {
case strings.HasPrefix(network, "tron"):
return tron.New(logger), nil
case strings.HasPrefix(network, "arbitrum"):
return arbitrum.New(logger), nil
case strings.HasPrefix(network, "ethereum"):
return ethereum.New(logger), nil
default:
return nil, merrors.InvalidArgument("unsupported chain network " + network)
}
}

View File

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

View File

@@ -4,6 +4,8 @@ import (
"strings" "strings"
"github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
) )
@@ -18,10 +20,10 @@ func WithKeyManager(manager keymanager.Manager) Option {
} }
} }
// WithTransferExecutor configures the executor responsible for on-chain submissions. // WithRPCClients configures pre-initialised RPC clients.
func WithTransferExecutor(executor TransferExecutor) Option { func WithRPCClients(clients *rpcclient.Clients) Option {
return func(s *Service) { return func(s *Service) {
s.executor = executor s.rpcClients = clients
} }
} }
@@ -59,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option {
} }
} }
// WithDriverRegistry configures the chain driver registry.
func WithDriverRegistry(registry *drivers.Registry) Option {
return func(s *Service) {
s.drivers = registry
}
}
// WithClock overrides the service clock. // WithClock overrides the service clock.
func WithClock(clk clockpkg.Clock) Option { func WithClock(clk clockpkg.Clock) Option {
return func(s *Service) { return func(s *Service) {

View File

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

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,8 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
@@ -38,11 +40,13 @@ type Service struct {
settings CacheSettings settings CacheSettings
networks map[string]shared.Network networks map[string]shared.Network
serviceWallet shared.ServiceWallet serviceWallet shared.ServiceWallet
keyManager keymanager.Manager keyManager keymanager.Manager
executor TransferExecutor rpcClients *rpcclient.Clients
commands commands.Registry networkRegistry *rpcclient.Registry
drivers *drivers.Registry
commands commands.Registry
chainv1.UnimplementedChainGatewayServiceServer chainv1.UnimplementedChainGatewayServiceServer
} }
@@ -73,6 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.networks = map[string]shared.Network{} svc.networks = map[string]shared.Network{}
} }
svc.settings = svc.settings.withDefaults() svc.settings = svc.settings.withDefaults()
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
svc.commands = commands.NewRegistry(commands.RegistryDeps{ svc.commands = commands.NewRegistry(commands.RegistryDeps{
Wallet: commandsWalletDeps(svc), Wallet: commandsWalletDeps(svc),
@@ -121,6 +126,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req) return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
} }
func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
}
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req)
}
func (s *Service) ensureRepository(ctx context.Context) error { func (s *Service) ensureRepository(ctx context.Context) error {
if s.storage == nil { if s.storage == nil {
return errStorageUnavailable return errStorageUnavailable
@@ -131,11 +144,13 @@ func (s *Service) ensureRepository(ctx context.Context) error {
func commandsWalletDeps(s *Service) wallet.Deps { func commandsWalletDeps(s *Service) wallet.Deps {
return wallet.Deps{ return wallet.Deps{
Logger: s.logger.Named("command"), Logger: s.logger.Named("command"),
Networks: s.networks, Drivers: s.drivers,
Networks: s.networkRegistry,
KeyManager: s.keyManager, KeyManager: s.keyManager,
Storage: s.storage, Storage: s.storage,
Clock: s.clock, Clock: s.clock,
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(), BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository, EnsureRepository: s.ensureRepository,
} }
} }
@@ -143,9 +158,11 @@ func commandsWalletDeps(s *Service) wallet.Deps {
func commandsTransferDeps(s *Service) transfer.Deps { func commandsTransferDeps(s *Service) transfer.Deps {
return transfer.Deps{ return transfer.Deps{
Logger: s.logger.Named("transfer_cmd"), Logger: s.logger.Named("transfer_cmd"),
Networks: s.networks, Drivers: s.drivers,
Networks: s.networkRegistry,
Storage: s.storage, Storage: s.storage,
Clock: s.clock, Clock: s.clock,
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository, EnsureRepository: s.ensureRepository,
LaunchExecution: s.launchTransferExecution, LaunchExecution: s.launchTransferExecution,
} }

View File

@@ -18,6 +18,7 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
@@ -65,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
require.Equal(t, 1, repo.wallets.count()) require.Equal(t, 1, repo.wallets.count())
} }
func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-native",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "ETH",
},
})
require.NoError(t, err)
require.NotNil(t, resp.GetWallet())
require.Equal(t, "ETH", resp.GetWallet().GetAsset().GetTokenSymbol())
require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress())
}
func TestSubmitTransfer_ManagedDestination(t *testing.T) { func TestSubmitTransfer_ManagedDestination(t *testing.T) {
svc, repo := newTestService(t) svc, repo := newTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -143,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
require.Equal(t, codes.NotFound, st.Code()) require.Equal(t, codes.NotFound, st.Code())
} }
func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) {
svc, repo := newTestService(t)
ctx := context.Background()
createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{
IdempotencyKey: "idem-balance",
OrganizationRef: "org-1",
OwnerRef: "owner-1",
Asset: &ichainv1.Asset{
Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET,
TokenSymbol: "USDC",
},
})
require.NoError(t, err)
walletRef := createResp.GetWallet().GetWalletRef()
err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{
WalletRef: walletRef,
Available: &moneyv1.Money{Currency: "USDC", Amount: "25"},
NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.5"},
CalculatedAt: time.Now().UTC(),
})
require.NoError(t, err)
resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef})
require.NoError(t, err)
require.NotNil(t, resp.GetBalance())
require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount())
require.Equal(t, "ETH", resp.GetBalance().GetNativeAvailable().GetCurrency())
}
// ---- in-memory storage implementation ---- // ---- in-memory storage implementation ----
type inMemoryRepository struct { type inMemoryRepository struct {
@@ -526,18 +577,23 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
return int64(requested) return int64(requested)
} }
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) { func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
repo := newInMemoryRepository() repo := newInMemoryRepository()
logger := zap.NewNop() logger := zap.NewNop()
networks := []shared.Network{{
Name: "ethereum_mainnet",
NativeToken: "ETH",
TokenConfigs: []shared.TokenContract{
{Symbol: "USDC", ContractAddress: "0xusdc"},
},
}}
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
require.NoError(t, err)
svc := NewService(logger, repo, nil, svc := NewService(logger, repo, nil,
WithKeyManager(&fakeKeyManager{}), WithKeyManager(&fakeKeyManager{}),
WithNetworks([]shared.Network{{ WithNetworks(networks),
Name: "ethereum_mainnet",
TokenConfigs: []shared.TokenContract{
{Symbol: "USDC", ContractAddress: "0xusdc"},
},
}}),
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}), WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
WithDriverRegistry(driverRegistry),
) )
return svc, repo return svc, repo
} }

View File

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

View File

@@ -0,0 +1,32 @@
package shared
import "github.com/shopspring/decimal"
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
type GasTopUpRule struct {
BufferPercent decimal.Decimal
MinNativeBalance decimal.Decimal
RoundingUnit decimal.Decimal
MaxTopUp decimal.Decimal
}
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
type GasTopUpPolicy struct {
Default GasTopUpRule
Native *GasTopUpRule
Contract *GasTopUpRule
}
// Rule selects the policy rule for the transfer type.
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
if p == nil {
return GasTopUpRule{}, false
}
if contractTransfer && p.Contract != nil {
return *p.Contract, true
}
if !contractTransfer && p.Native != nil {
return *p.Native, true
}
return p.Default, true
}

View File

@@ -121,11 +121,12 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
// Network describes a supported blockchain network and known token contracts. // Network describes a supported blockchain network and known token contracts.
type Network struct { type Network struct {
Name string Name string
RPCURL string RPCURL string
ChainID uint64 ChainID uint64
NativeToken string NativeToken string
TokenConfigs []TokenContract TokenConfigs []TokenContract
GasTopUpPolicy *GasTopUpPolicy
} }
// TokenContract captures the metadata needed to work with a specific on-chain token. // TokenContract captures the metadata needed to work with a specific on-chain token.

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

@@ -7,15 +7,15 @@ import (
"time" "time"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
) )
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) { func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
if s.executor == nil { if s.drivers == nil {
return return
} }
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
defer cancel() defer cancel()
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil { if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err)) s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
} }
}(transferRef, sourceWalletRef, network) }(transferRef, sourceWalletRef, network)
} }
@@ -44,13 +44,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination) driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name)
if err != nil { if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err return err
} }
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network) destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
if err != nil { if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err return err
@@ -62,7 +69,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash) receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
if err != nil { if err != nil {
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
@@ -83,7 +90,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
return nil return nil
} }
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) { func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" { if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
wallet, err := s.storage.Wallets().Get(ctx, ref) wallet, err := s.storage.Wallets().Get(ctx, ref)
if err != nil { if err != nil {
@@ -92,10 +99,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
if strings.TrimSpace(wallet.DepositAddress) == "" { if strings.TrimSpace(wallet.DepositAddress) == "" {
return "", merrors.Internal("destination wallet missing deposit address") return "", merrors.Internal("destination wallet missing deposit address")
} }
return wallet.DepositAddress, nil return chainDriver.NormalizeAddress(wallet.DepositAddress)
} }
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
return strings.ToLower(addr), nil return chainDriver.NormalizeAddress(addr)
} }
return "", merrors.InvalidArgument("transfer destination address not resolved") return "", merrors.InvalidArgument("transfer destination address not resolved")
} }
func (s *Service) driverDeps() driver.Deps {
return driver.Deps{
Logger: s.logger.Named("driver"),
Registry: s.networkRegistry,
KeyManager: s.keyManager,
RPCTimeout: s.settings.rpcTimeout(),
}
}
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
if s.drivers == nil {
return nil, merrors.Internal("chain drivers not configured")
}
return s.drivers.Driver(network)
}

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
) )
@@ -19,7 +20,8 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service. // ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct { type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"` WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -45,6 +47,7 @@ type WalletBalance struct {
WalletRef string `bson:"walletRef" json:"walletRef"` WalletRef string `bson:"walletRef" json:"walletRef"`
Available *moneyv1.Money `bson:"available" json:"available"` Available *moneyv1.Money `bson:"available" json:"available"`
NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"`
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"` PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"` PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"` CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
@@ -77,10 +80,19 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef) m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef) m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef) m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network)) m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress)) m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
m.KeyReference = strings.TrimSpace(m.KeyReference) m.KeyReference = strings.TrimSpace(m.KeyReference)
} }
@@ -88,3 +100,31 @@ func (m *ManagedWallet) Normalize() {
func (b *WalletBalance) Normalize() { func (b *WalletBalance) Normalize() {
b.WalletRef = strings.TrimSpace(b.WalletRef) b.WalletRef = strings.TrimSpace(b.WalletRef)
} }
func normalizeWalletAddress(address string) string {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return ""
}
if isHexAddress(trimmed) {
return strings.ToLower(trimmed)
}
return trimmed
}
func isHexAddress(value string) bool {
trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x")
if len(trimmed) != 40 && len(trimmed) != 42 {
return false
}
for _, r := range trimmed {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}

View File

@@ -0,0 +1,84 @@
package client
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Client wraps the Monetix gateway gRPC API.
type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
Close() error
}
type gatewayClient struct {
conn *grpc.ClientConn
client mntxv1.MntxGatewayServiceClient
cfg Config
}
// New dials the Monetix gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("mntx: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
dialOpts = append(dialOpts, opts...)
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
}
return &gatewayClient{
conn: conn,
client: mntxv1.NewMntxGatewayServiceClient(conn),
cfg: cfg,
}, nil
}
func (g *gatewayClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := g.cfg.CallTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardPayout(ctx, req)
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardTokenPayout(ctx, req)
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
}

View File

@@ -0,0 +1,19 @@
package client
import "time"
// Config holds Monetix gateway client settings.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second
}
}

View File

@@ -0,0 +1,37 @@
package client
import (
"context"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if f.CreateCardPayoutFn != nil {
return f.CreateCardPayoutFn(ctx, req)
}
return &mntxv1.CardPayoutResponse{}, nil
}
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if f.CreateCardTokenPayoutFn != nil {
return f.CreateCardTokenPayoutFn(ctx, req)
}
return &mntxv1.CardTokenPayoutResponse{}, nil
}
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
if f.GetCardPayoutStatusFn != nil {
return f.GetCardPayoutStatusFn(ctx, req)
}
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

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

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

View File

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

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

View File

@@ -62,12 +62,6 @@ const (
OutboxStatusFailed OutboxStatus = "failed" OutboxStatusFailed OutboxStatus = "failed"
) )
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities. // LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct { type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

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

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

View File

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

View File

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

View File

@@ -56,3 +56,11 @@ oracle:
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 3 call_timeout_seconds: 3
insecure: true insecure: true
card_gateways:
monetix:
funding_address: "TXtjmjF99MhMdaMQrLopzcQ8cSBRLq5co8"
fee_wallet_ref: "694c124fd76f9f811ac57134"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -8,6 +8,8 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/ledger => ../../ledger
@@ -17,12 +19,13 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -42,7 +45,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -59,5 +62,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

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/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
@@ -215,12 +215,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
chainclient "github.com/tech/sendico/gateway/chain/client"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
@@ -41,10 +41,12 @@ type Imp struct {
type config struct { type config struct {
*grpcapp.Config `yaml:",inline"` *grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"` Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"` Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"` Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"` Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
} }
type clientConfig struct { type clientConfig struct {
@@ -54,6 +56,12 @@ type clientConfig struct {
InsecureTransport bool `yaml:"insecure"` InsecureTransport bool `yaml:"insecure"`
} }
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
FeeWalletRef string `yaml:"fee_wallet_ref"`
}
func (c clientConfig) address() string { func (c clientConfig) address() string {
return strings.TrimSpace(c.Address) return strings.TrimSpace(c.Address)
} }
@@ -107,7 +115,7 @@ func (i *Imp) Shutdown() {
func (i *Imp) Start() error { func (i *Imp) Start() error {
cfg, err := i.loadConfig() cfg, err := i.loadConfig()
if err != nil { if err != nil {
return err return err
} }
i.config = cfg i.config = cfg
@@ -150,6 +158,12 @@ func (i *Imp) Start() error {
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient)) opts = append(opts, orchestrator.WithOracleClient(oracleClient))
} }
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
return orchestrator.NewService(logger, repo, opts...), nil return orchestrator.NewService(logger, repo, opts...), nil
} }
@@ -296,3 +310,38 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil return cfg, nil
} }
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
}
}
return result
}
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
result := make(map[string]string, len(src))
for key, account := range src {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(account)
if k == "" || v == "" {
continue
}
result[k] = v
}
return result
}

View File

@@ -0,0 +1,641 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
const (
defaultCardGateway = "monetix"
stepCodeGasTopUp = "gas_top_up"
stepCodeFundingTransfer = "funding_transfer"
stepCodeCardPayout = "card_payout"
stepCodeFeeTransfer = "fee_transfer"
)
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
}
key := strings.ToLower(strings.TrimSpace(gateway))
if key == "" {
key = defaultCardGateway
}
route, ok := s.deps.cardRoutes[key]
if !ok {
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
source := intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card funding: source managed wallet is required")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
fundingAddress := strings.TrimSpace(route.FundingAddress)
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: amount is required")
}
payoutAmount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
feeMoney := (*moneyv1.Money)(nil)
if quote != nil {
feeMoney = quote.GetExpectedFeeTotal()
}
if feeMoney == nil && payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
feeDecimal := decimal.Zero
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: fee currency is required")
}
feeDecimal, err = decimalFromMoney(feeMoney)
if err != nil {
return err
}
}
feeRequired := feeDecimal.IsPositive()
fundingDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}
fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount)
if err != nil {
return err
}
var feeTransferFee *moneyv1.Money
if feeRequired {
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
}
feeDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
}
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney)
if err != nil {
return err
}
}
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
if err != nil {
return err
}
var estimatedTotalFee *moneyv1.Money
if gasCurrency != "" && !totalFee.IsNegative() {
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
}
var topUpMoney *moneyv1.Money
var topUpFee *moneyv1.Money
topUpPositive := false
if estimatedTotalFee != nil {
computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
})
if err != nil {
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if computeResp != nil {
topUpMoney = computeResp.GetTopupAmount()
}
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
amountDec, err := decimalFromMoney(topUpMoney)
if err != nil {
return err
}
topUpPositive = amountDec.IsPositive()
}
if topUpMoney != nil && topUpPositive {
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
if err != nil {
return err
}
}
}
plan := ensureExecutionPlan(payment)
var gasStep *model.ExecutionStep
if topUpMoney != nil && topUpPositive {
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
gasStep.Description = "Top up native gas from fee wallet"
gasStep.Amount = cloneMoney(topUpMoney)
gasStep.NetworkFee = cloneMoney(topUpFee)
gasStep.SourceWalletRef = feeWalletRef
gasStep.DestinationRef = sourceWalletRef
}
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
fundStep.Description = "Transfer payout amount to card funding wallet"
fundStep.Amount = cloneMoney(amount)
fundStep.NetworkFee = cloneMoney(fundingFee)
fundStep.SourceWalletRef = sourceWalletRef
fundStep.DestinationRef = fundingAddress
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
cardStep.Description = "Submit card payout"
cardStep.Amount = cloneMoney(payoutAmount)
if card := intent.Destination.Card; card != nil {
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
cardStep.DestinationRef = masked
}
}
if feeRequired {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.NetworkFee = cloneMoney(feeTransferFee)
step.SourceWalletRef = sourceWalletRef
step.DestinationRef = feeWalletRef
}
updateExecutionPlanTotalNetworkFee(plan)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if topUpMoney != nil && topUpPositive {
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: feeWalletRef,
TargetWalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
})
if gasErr != nil {
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
return gasErr
}
if gasStep != nil {
actual := (*moneyv1.Money)(nil)
if ensureResp != nil {
actual = ensureResp.GetTopupAmount()
if transfer := ensureResp.GetTransfer(); transfer != nil {
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
}
}
actualPositive := false
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
actualDec, err := decimalFromMoney(actual)
if err != nil {
return err
}
actualPositive = actualDec.IsPositive()
}
if actual != nil && actualPositive {
gasStep.Amount = cloneMoney(actual)
if strings.TrimSpace(actual.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = cloneMoney(topUpFee)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
}
}
if gasStep != nil {
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
}
updateExecutionPlanTotalNetworkFee(plan)
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
if err != nil {
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
fundStep.TransferRef = exec.ChainTransferRef
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
payment.Execution = exec
return nil
}
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
var (
state *mntxv1.CardPayoutState
)
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else {
return merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if exec.CardPayoutRef == "" {
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
payment.Execution = exec
plan := ensureExecutionPlan(payment)
if plan != nil {
step := ensureExecutionStep(plan, stepCodeCardPayout)
step.Description = "Submit card payout"
step.Amount = cloneMoney(amount)
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
step.DestinationRef = masked
}
if exec.CardPayoutRef != "" {
step.TransferRef = exec.CardPayoutRef
}
updateExecutionPlanTotalNetworkFee(plan)
}
feeMoney := (*moneyv1.Money)(nil)
if payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: fee currency is required")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
if !s.deps.gateway.available() {
s.logger.Warn("card fee aborted: chain gateway unavailable")
return merrors.InvalidArgument("card payout: chain gateway unavailable")
}
sourceWallet := intent.Source.ManagedWallet
if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card payout: source managed wallet is required")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
if feeWalletRef == "" {
return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists")
}
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
if plan != nil {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef)
step.DestinationRef = feeWalletRef
step.TransferRef = exec.FeeTransferRef
updateExecutionPlanTotalNetworkFee(plan)
}
}
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef))
return nil
}
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
if payment == nil || state == nil {
return
}
if payment.CardPayout == nil {
payment.CardPayout = &model.CardPayout{}
}
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
payment.CardPayout.Status = state.GetStatus().String()
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
}
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
}
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
}
recordCardPayoutState(payment, payout)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}
func cardPayoutAmount(payment *model.Payment) (*moneyv1.Money, error) {
if payment == nil {
return nil, merrors.InvalidArgument("payment is required")
}
amount := cloneMoney(payment.Intent.Amount)
if payment.LastQuote != nil {
settlement := payment.LastQuote.ExpectedSettlementAmount
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
amount = cloneMoney(settlement)
}
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("card payout: amount is required")
}
return amount, nil
}
func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
if !s.deps.gateway.available() {
return nil, merrors.InvalidArgument("chain gateway unavailable")
}
sourceWalletRef = strings.TrimSpace(sourceWalletRef)
if sourceWalletRef == "" {
return nil, merrors.InvalidArgument("source wallet ref is required")
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("amount is required")
}
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: sourceWalletRef,
Destination: destination,
Amount: cloneMoney(amount),
})
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
if resp == nil {
s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
fee := resp.GetNetworkFee()
if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return cloneMoney(fee), nil
}
func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) {
total := decimal.Zero
currency := ""
for _, fee := range fees {
if fee == nil {
continue
}
amount := strings.TrimSpace(fee.GetAmount())
feeCurrency := strings.TrimSpace(fee.GetCurrency())
if amount == "" || feeCurrency == "" {
return decimal.Zero, "", merrors.InvalidArgument("network fee is required")
}
value, err := decimalFromMoney(fee)
if err != nil {
return decimal.Zero, "", err
}
if currency == "" {
currency = feeCurrency
} else if !strings.EqualFold(currency, feeCurrency) {
return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch")
}
total = total.Add(value)
}
return total, currency, nil
}
func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan {
if payment == nil {
return nil
}
if payment.ExecutionPlan == nil {
payment.ExecutionPlan = &model.ExecutionPlan{}
}
return payment.ExecutionPlan
}
func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep {
if plan == nil {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
return nil
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if strings.EqualFold(step.Code, code) {
if step.Code == "" {
step.Code = code
}
return step
}
}
step := &model.ExecutionStep{Code: code}
plan.Steps = append(plan.Steps, step)
return step
}
func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) {
if plan == nil {
return
}
total := decimal.Zero
currency := ""
hasFee := false
for _, step := range plan.Steps {
if step == nil || step.NetworkFee == nil {
continue
}
fee := step.NetworkFee
if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
continue
}
if currency == "" {
currency = strings.TrimSpace(fee.GetCurrency())
} else if !strings.EqualFold(currency, fee.GetCurrency()) {
continue
}
value, err := decimalFromMoney(fee)
if err != nil {
continue
}
total = total.Add(value)
hasFee = true
}
if !hasFee || currency == "" {
plan.TotalNetworkFee = nil
return
}
plan.TotalNetworkFee = makeMoney(currency, total)
}

View File

@@ -0,0 +1,407 @@
package orchestrator
import (
"context"
"strings"
"testing"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mo "github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
ctx := context.Background()
const (
sourceWalletRef = "wallet-src"
feeWalletRef = "wallet-fee"
fundingAddress = "0xfunding"
)
var estimateCalls []*chainv1.EstimateTransferFeeRequest
var computeCalls []*chainv1.ComputeGasTopUpRequest
var ensureCalls []*chainv1.EnsureGasTopUpRequest
var submitCalls []*chainv1.SubmitTransferRequest
gateway := &chainclient.Fake{
EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
estimateCalls = append(estimateCalls, req)
dest := req.GetDestination()
if req.GetSourceWalletRef() == feeWalletRef {
return &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.005"},
}, nil
}
if dest != nil && strings.TrimSpace(dest.GetExternalAddress()) != "" {
return &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"},
}, nil
}
return &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"},
}, nil
},
ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
computeCalls = append(computeCalls, req)
return &chainv1.ComputeGasTopUpResponse{
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
}, nil
},
EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
ensureCalls = append(ensureCalls, req)
return &chainv1.EnsureGasTopUpResponse{
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
}, nil
},
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitCalls = append(submitCalls, req)
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
}, nil
},
}
svc := &Service{
logger: zap.NewNop(),
deps: serviceDependencies{
gateway: gatewayDependency{client: gateway},
cardRoutes: map[string]CardGatewayRoute{
defaultCardGateway: {
FundingAddress: fundingAddress,
FeeWalletRef: feeWalletRef,
},
},
},
}
payment := &model.Payment{
PaymentRef: "pay-1",
IdempotencyKey: "pay-1",
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: sourceWalletRef,
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
MaskedPan: "4111",
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
},
}
quote := &orchestratorv1.PaymentQuote{
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
}
if err := svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
t.Fatalf("submitCardFundingTransfers error: %v", err)
}
if len(estimateCalls) != 4 {
t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls))
}
if len(computeCalls) != 1 {
t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls))
}
if len(ensureCalls) != 1 {
t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls))
}
if len(submitCalls) != 1 {
t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls))
}
computeCall := computeCalls[0]
if computeCall.GetWalletRef() != sourceWalletRef {
t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef())
}
if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount())
}
ensureCall := ensureCalls[0]
if ensureCall.GetSourceWalletRef() != feeWalletRef {
t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef())
}
if ensureCall.GetTargetWalletRef() != sourceWalletRef {
t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef())
}
if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount())
}
fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund")
if fundCall.GetDestination().GetExternalAddress() != fundingAddress {
t.Fatalf("funding destination mismatch: %s", fundCall.GetDestination().GetExternalAddress())
}
if fundCall.GetAmount().GetCurrency() != "USDT" || fundCall.GetAmount().GetAmount() != "5" {
t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount())
}
if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" {
t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution)
}
plan := payment.ExecutionPlan
if plan == nil {
t.Fatal("expected execution plan to be populated")
}
gasStep := findExecutionStep(t, plan, stepCodeGasTopUp)
if gasStep.Amount.GetAmount() != "0.025" || gasStep.Amount.GetCurrency() != "ETH" {
t.Fatalf("gas step amount mismatch: %s %s", gasStep.Amount.GetCurrency(), gasStep.Amount.GetAmount())
}
if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" {
t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount())
}
if gasStep.TransferRef != "pay-1:card:gas" {
t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef)
}
fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer)
if fundStep.NetworkFee.GetAmount() != "0.01" || fundStep.NetworkFee.GetCurrency() != "ETH" {
t.Fatalf("funding step fee mismatch: %s %s", fundStep.NetworkFee.GetCurrency(), fundStep.NetworkFee.GetAmount())
}
if fundStep.TransferRef != "pay-1:card:fund" {
t.Fatalf("funding step transfer ref mismatch: %s", fundStep.TransferRef)
}
cardStep := findExecutionStep(t, plan, stepCodeCardPayout)
if cardStep.Amount.GetAmount() != "5" || cardStep.Amount.GetCurrency() != "USDT" {
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
}
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
}
if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" {
t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount())
}
if feeStep.TransferRef != "" {
t.Fatalf("expected fee step transfer ref to be empty before payout, got %s", feeStep.TransferRef)
}
if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" {
t.Fatalf("total network fee mismatch: %v", plan.TotalNetworkFee)
}
}
func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
ctx := context.Background()
const (
sourceWalletRef = "wallet-src"
feeWalletRef = "wallet-fee"
)
var payoutReq *mntxv1.CardPayoutRequest
var submitCalls []*chainv1.SubmitTransferRequest
gateway := &chainclient.Fake{
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitCalls = append(submitCalls, req)
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{TransferRef: "fee-transfer"},
}, nil
},
}
mntx := &mntxclient.Fake{
CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
payoutReq = req
return &mntxv1.CardPayoutResponse{
Payout: &mntxv1.CardPayoutState{
PayoutId: "payout-1",
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
},
}, nil
},
}
svc := &Service{
logger: zap.NewNop(),
deps: serviceDependencies{
gateway: gatewayDependency{client: gateway},
mntx: mntxDependency{client: mntx},
cardRoutes: map[string]CardGatewayRoute{
defaultCardGateway: {
FundingAddress: "0xfunding",
FeeWalletRef: feeWalletRef,
},
},
},
}
payment := &model.Payment{
PaymentRef: "pay-2",
IdempotencyKey: "pay-2",
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: sourceWalletRef,
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Pan: "5536913762657597",
Cardholder: "Stephan",
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
},
LastQuote: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"},
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
},
}
if err := svc.submitCardPayout(ctx, payment); err != nil {
t.Fatalf("submitCardPayout error: %v", err)
}
if payoutReq == nil {
t.Fatal("expected card payout request to be sent")
}
if payoutReq.GetCurrency() != "RUB" || payoutReq.GetAmountMinor() != 39230 {
t.Fatalf("payout request amount mismatch: %s %d", payoutReq.GetCurrency(), payoutReq.GetAmountMinor())
}
if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" {
t.Fatalf("expected card payout ref recorded, got %v", payment.Execution)
}
if payment.Execution.FeeTransferRef != "fee-transfer" {
t.Fatalf("expected fee transfer ref recorded, got %v", payment.Execution)
}
if len(submitCalls) != 1 {
t.Fatalf("expected 1 fee transfer submission, got %d", len(submitCalls))
}
feeCall := submitCalls[0]
if feeCall.GetSourceWalletRef() != sourceWalletRef {
t.Fatalf("fee transfer source mismatch: %s", feeCall.GetSourceWalletRef())
}
if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef {
t.Fatalf("fee transfer destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef())
}
plan := payment.ExecutionPlan
if plan == nil {
t.Fatal("expected execution plan to be populated")
}
cardStep := findExecutionStep(t, plan, stepCodeCardPayout)
if cardStep.TransferRef != "payout-1" {
t.Fatalf("card step transfer ref mismatch: %s", cardStep.TransferRef)
}
if cardStep.Amount.GetAmount() != "392.30" || cardStep.Amount.GetCurrency() != "RUB" {
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
}
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
if feeStep.TransferRef != "fee-transfer" {
t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef)
}
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
}
}
func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) {
ctx := context.Background()
gateway := &chainclient.Fake{
EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
return &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"},
}, nil
},
}
svc := &Service{
logger: zap.NewNop(),
deps: serviceDependencies{
gateway: gatewayDependency{client: gateway},
cardRoutes: map[string]CardGatewayRoute{
defaultCardGateway: {
FundingAddress: "0xfunding",
},
},
},
}
payment := &model.Payment{
PaymentRef: "pay-3",
IdempotencyKey: "pay-3",
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
MaskedPan: "4111",
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
},
}
quote := &orchestratorv1.PaymentQuote{
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
}
err := svc.submitCardFundingTransfers(ctx, payment, quote)
if err == nil {
t.Fatal("expected error for missing fee wallet ref")
}
if !strings.Contains(err.Error(), "fee wallet ref") {
t.Fatalf("unexpected error: %v", err)
}
}
func findSubmitCall(t *testing.T, calls []*chainv1.SubmitTransferRequest, idempotencyKey string) *chainv1.SubmitTransferRequest {
t.Helper()
for _, call := range calls {
if call.GetIdempotencyKey() == idempotencyKey {
return call
}
}
t.Fatalf("missing submit transfer call for %s", idempotencyKey)
return nil
}
func findExecutionStep(t *testing.T, plan *model.ExecutionPlan, code string) *model.ExecutionStep {
t.Helper()
if plan == nil {
t.Fatal("execution plan is nil")
}
for _, step := range plan.Steps {
if step != nil && strings.EqualFold(step.Code, code) {
return step
}
}
t.Fatalf("missing execution step %s", code)
return nil
}

View File

@@ -0,0 +1,97 @@
package orchestrator
import (
"context"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository
}
type defaultPaymentEngine struct {
svc *Service
}
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
return e.svc.ensureRepository(ctx)
}
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
return e.svc.resolvePaymentQuote(ctx, in)
}
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
return e.svc.executePayment(ctx, store, payment, quote)
}
func (e defaultPaymentEngine) Repository() storage.Repository {
return e.svc.storage
}
type paymentCommandFactory struct {
engine paymentEngine
logger mlogger.Logger
}
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
return &paymentCommandFactory{
engine: engine,
logger: logger.Named("commands"),
}
}
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
return &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote_payment"),
}
}
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,
logger: f.logger.Named("initiate_payment"),
}
}
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
return &initiatePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payments"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,
logger: f.logger.Named("cancel_payment"),
}
}
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
return &initiateConversionCommand{
engine: f.engine,
logger: f.logger.Named("initiate_conversion"),
}
}

View File

@@ -5,9 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
@@ -20,13 +18,14 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
return model.PaymentIntent{} return model.PaymentIntent{}
} }
intent := model.PaymentIntent{ intent := model.PaymentIntent{
Kind: modelKindFromProto(src.GetKind()), Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()), Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()), Destination: endpointFromProto(src.GetDestination()),
Amount: cloneMoney(src.GetAmount()), Amount: cloneMoney(src.GetAmount()),
RequiresFX: src.GetRequiresFx(), RequiresFX: src.GetRequiresFx(),
FeePolicy: src.GetFeePolicy(), FeePolicy: src.GetFeePolicy(),
Attributes: cloneMetadata(src.GetAttributes()), SettlementMode: src.GetSettlementMode(),
Attributes: cloneMetadata(src.GetAttributes()),
} }
if src.GetFx() != nil { if src.GetFx() != nil {
intent.FX = fxIntentFromProto(src.GetFx()) intent.FX = fxIntentFromProto(src.GetFx())
@@ -67,6 +66,19 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
} }
return result return result
} }
if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()),
ExpMonth: card.GetExpMonth(),
ExpYear: card.GetExpYear(),
Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
}
return result
}
return result return result
} }
@@ -96,7 +108,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
FeeRules: cloneFeeRules(src.GetFeeRules()), FeeRules: cloneFeeRules(src.GetFeeRules()),
FXQuote: cloneFXQuote(src.GetFxQuote()), FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()), NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()), QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
} }
} }
@@ -114,8 +125,21 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
FailureReason: src.FailureReason, FailureReason: src.FailureReason,
LastQuote: modelQuoteToProto(src.LastQuote), LastQuote: modelQuoteToProto(src.LastQuote),
Execution: protoExecutionFromModel(src.Execution), Execution: protoExecutionFromModel(src.Execution),
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
Metadata: cloneMetadata(src.Metadata), Metadata: cloneMetadata(src.Metadata),
} }
if src.CardPayout != nil {
payment.CardPayout = &orchestratorv1.CardPayout{
PayoutRef: src.CardPayout.PayoutRef,
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
Status: src.CardPayout.Status,
FailureReason: src.CardPayout.FailureReason,
CardCountry: src.CardPayout.CardCountry,
MaskedPan: src.CardPayout.MaskedPan,
ProviderCode: src.CardPayout.ProviderCode,
GatewayReference: src.CardPayout.GatewayReference,
}
}
if src.CreatedAt.IsZero() { if src.CreatedAt.IsZero() {
payment.CreatedAt = timestamppb.New(time.Now().UTC()) payment.CreatedAt = timestamppb.New(time.Now().UTC())
} else { } else {
@@ -129,13 +153,14 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
intent := &orchestratorv1.PaymentIntent{ intent := &orchestratorv1.PaymentIntent{
Kind: protoKindFromModel(src.Kind), Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source), Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination), Destination: protoEndpointFromModel(src.Destination),
Amount: cloneMoney(src.Amount), Amount: cloneMoney(src.Amount),
RequiresFx: src.RequiresFX, RequiresFx: src.RequiresFX,
FeePolicy: src.FeePolicy, FeePolicy: src.FeePolicy,
Attributes: cloneMetadata(src.Attributes), SettlementMode: src.SettlementMode,
Attributes: cloneMetadata(src.Attributes),
} }
if src.FX != nil { if src.FX != nil {
intent.Fx = protoFXIntentFromModel(src.FX) intent.Fx = protoFXIntentFromModel(src.FX)
@@ -176,6 +201,23 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
}, },
} }
} }
case model.EndpointTypeCard:
if src.Card != nil {
card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder,
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
}
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(src.Card.Token); token != "" {
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
}
default: default:
// leave unspecified // leave unspecified
} }
@@ -205,6 +247,43 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
CreditEntryRef: src.CreditEntryRef, CreditEntryRef: src.CreditEntryRef,
FxEntryRef: src.FXEntryRef, FxEntryRef: src.FXEntryRef,
ChainTransferRef: src.ChainTransferRef, ChainTransferRef: src.ChainTransferRef,
CardPayoutRef: src.CardPayoutRef,
FeeTransferRef: src.FeeTransferRef,
}
}
func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep {
if src == nil {
return nil
}
return &orchestratorv1.ExecutionStep{
Code: src.Code,
Description: src.Description,
Amount: cloneMoney(src.Amount),
NetworkFee: cloneMoney(src.NetworkFee),
SourceWalletRef: src.SourceWalletRef,
DestinationRef: src.DestinationRef,
TransferRef: src.TransferRef,
Metadata: cloneMetadata(src.Metadata),
}
}
func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan {
if src == nil {
return nil
}
steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps))
for _, step := range src.Steps {
if protoStep := protoExecutionStepFromModel(step); protoStep != nil {
steps = append(steps, protoStep)
}
}
if len(steps) == 0 {
steps = nil
}
return &orchestratorv1.ExecutionPlan{
Steps: steps,
TotalNetworkFee: cloneMoney(src.TotalNetworkFee),
} }
} }
@@ -220,7 +299,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
FeeRules: cloneFeeRules(src.FeeRules), FeeRules: cloneFeeRules(src.FeeRules),
FxQuote: cloneFXQuote(src.FXQuote), FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee), NetworkFee: cloneNetworkEstimate(src.NetworkFee),
FeeQuoteToken: src.FeeQuoteToken,
QuoteRef: strings.TrimSpace(src.QuoteRef), QuoteRef: strings.TrimSpace(src.QuoteRef),
} }
} }
@@ -369,60 +447,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
} }
return nil return nil
} }
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
switch code {
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
return model.PaymentFailureCodeBalance
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
return model.PaymentFailureCodeLedger
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
return model.PaymentFailureCodeFX
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
return model.PaymentFailureCodeChain
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
return model.PaymentFailureCodeFees
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
return model.PaymentFailureCodePolicy
default:
return model.PaymentFailureCodeUnspecified
}
}
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
if src == nil || dst == nil {
return merrors.InvalidArgument("payment payload is required")
}
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
dst.Intent = intentFromProto(src.GetIntent())
dst.State = modelStateFromProto(src.GetState())
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
return nil
}
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
if src == nil {
return nil
}
return &model.ExecutionRefs{
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
}
}
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
if req == nil {
return &paginationv1.CursorPageRequest{}
}
if req.GetPage() == nil {
return &paginationv1.CursorPageRequest{}
}
return req.GetPage()
}

View File

@@ -0,0 +1,69 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestEndpointFromProtoCard(t *testing.T) {
protoEndpoint := &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
CardholderName: " Jane Doe ",
ExpMonth: 12,
ExpYear: 2030,
Country: " US ",
MaskedPan: " ****1111 ",
},
},
Metadata: map[string]string{"k": "v"},
}
modelEndpoint := endpointFromProto(protoEndpoint)
if modelEndpoint.Type != model.EndpointTypeCard {
t.Fatalf("expected card type, got %s", modelEndpoint.Type)
}
if modelEndpoint.Card == nil {
t.Fatalf("card payload missing")
}
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
}
if modelEndpoint.Metadata["k"] != "v" {
t.Fatalf("metadata not preserved")
}
}
func TestProtoEndpointFromModelCard(t *testing.T) {
modelEndpoint := model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Token: "tok_123",
Cardholder: "Jane",
ExpMonth: 1,
ExpYear: 2028,
Country: "GB",
MaskedPan: "****1234",
},
Metadata: map[string]string{"k": "v"},
}
protoEndpoint := protoEndpointFromModel(modelEndpoint)
card := protoEndpoint.GetCard()
if card == nil {
t.Fatalf("card payload missing in proto")
}
token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token)
if !ok || token.Token != "tok_123" {
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
}
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
t.Fatalf("card details mismatch: %#v", card)
}
if protoEndpoint.GetMetadata()["k"] != "v" {
t.Fatalf("metadata not preserved in proto endpoint")
}
}

View File

@@ -0,0 +1,446 @@
package orchestrator
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type quotePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := requireNonNilIntent(req.GetIntent()); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
type quotePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := req.GetIntents()
if len(intents) == 0 {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
}
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteReq := &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
Intent: intent,
PreviewOnly: req.GetPreviewOnly(),
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quotes = append(quotes, quote)
expires = append(expires, expiresAt)
}
aggregate, err := aggregatePaymentQuotes(quotes)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
}
expiresAt, ok := minQuoteExpiry(expires)
if !ok {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
}
quoteRef := ""
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef = primitive.NewObjectID().Hex()
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: quoteRef,
Aggregate: aggregate,
Quotes: quotes,
})
}
type initiatePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
if quoteRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
}
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := record.Intents
quotes := record.Quotes
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
intents = []model.PaymentIntent{record.Intent}
}
if len(quotes) == 0 && record.Quote != nil {
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
}
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments := make([]*orchestratorv1.Payment, 0, len(intents))
for i := range intents {
intentProto := protoIntentFromModel(intents[i])
if err := requireNonNilIntent(intentProto); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto := modelQuoteToProto(quotes[i])
if quoteProto == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing))
continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments = append(payments, toProtoPayment(entity))
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: req.GetQuoteRef(),
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
if qerr, ok := err.(quoteResolutionError); ok {
switch qerr.code {
case "quote_not_found":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default:
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
}
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
type cancelPaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
type initiateConversionCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}

View File

@@ -0,0 +1,139 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentEventHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
}
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
}
payout := req.GetEvent().GetPayout()
paymentRef := strings.TrimSpace(payout.GetPayoutId())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyCardPayoutUpdate(payment, payout)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
Payment: toProtoPayment(payment),
})
}

View File

@@ -0,0 +1,80 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentQueryHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
return &paymentQueryHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
return gsresponse.Success(resp)
}

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

View File

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

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
@@ -51,10 +53,17 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil { if intent == nil {
return false return false
} }
dest := intent.GetDestination()
if dest == nil {
return false
}
if dest.GetCard() != nil {
return false
}
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT { if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
return true return true
} }
if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil { if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil {
return true return true
} }
return false return false
@@ -69,3 +78,16 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
} }
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
} }
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return model.PaymentStateSettled
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PaymentStateFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return model.PaymentStateSubmitted
default:
return model.PaymentStateUnspecified
}
}

View File

@@ -0,0 +1,51 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{},
},
},
}
if shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation to be skipped for card payouts")
}
}
func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"},
},
},
}
if !shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation when destination is managed wallet")
}
}
func TestMapMntxStatusToState(t *testing.T) {
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled {
t.Fatalf("processed should map to settled")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed {
t.Fatalf("failed should map to failed")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted {
t.Fatalf("pending should map to submitted")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified {
t.Fatalf("unspecified should map to unspecified")
}
}

View File

@@ -1,10 +1,12 @@
package orchestrator package orchestrator
import ( import (
"strings"
"time" "time"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client" chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
@@ -46,10 +48,25 @@ func (o oracleDependency) available() bool {
return o.client != nil return o.client != nil
} }
type mntxDependency struct {
client mntxclient.Client
}
func (m mntxDependency) available() bool {
return m.client != nil
}
// CardGatewayRoute maps a gateway to its funding and fee destinations.
type CardGatewayRoute struct {
FundingAddress string
FeeAddress string
FeeWalletRef string
}
// WithFeeEngine wires the fee engine client. // WithFeeEngine wires the fee engine client.
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
return func(s *Service) { return func(s *Service) {
s.fees = feesDependency{ s.deps.fees = feesDependency{
client: client, client: client,
timeout: timeout, timeout: timeout,
} }
@@ -59,21 +76,59 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
// WithLedgerClient wires the ledger client. // WithLedgerClient wires the ledger client.
func WithLedgerClient(client ledgerclient.Client) Option { func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.ledger = ledgerDependency{client: client} s.deps.ledger = ledgerDependency{client: client}
} }
} }
// WithChainGatewayClient wires the chain gateway client. // WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option { func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.gateway = gatewayDependency{client: client} s.deps.gateway = gatewayDependency{client: client}
} }
} }
// WithOracleClient wires the FX oracle client. // WithOracleClient wires the FX oracle client.
func WithOracleClient(client oracleclient.Client) Option { func WithOracleClient(client oracleclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.oracle = oracleDependency{client: client} s.deps.oracle = oracleDependency{client: client}
}
}
// WithMntxGateway wires the Monetix gateway client.
func WithMntxGateway(client mntxclient.Client) Option {
return func(s *Service) {
s.deps.mntx = mntxDependency{client: client}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
for k, v := range routes {
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
}
}
}
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
func WithFeeLedgerAccounts(routes map[string]string) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
for k, v := range routes {
key := strings.ToLower(strings.TrimSpace(k))
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
s.deps.feeLedgerAccounts[key] = val
}
} }
} }

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