176 Commits

Author SHA1 Message Date
5d443230f4 Merge pull request 'TTL for discovery values' (#234) from discovery-233 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
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/frontend Pipeline is running
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #234
2026-01-06 13:20:27 +00:00
Stephan D
3e83cc51d7 TTL for discovery values 2026-01-06 14:20:08 +01:00
9c2ef52d07 Merge pull request '+ ledger account open endpoint' (#232) from ledger-231 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/discovery 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/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle 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
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #232
2026-01-05 12:03:10 +00:00
Stephan D
e84854d875 fixed mntx compilation 2026-01-05 13:02:47 +01:00
Stephan D
2f34b5a827 + ledger account open endpoint 2026-01-05 12:57:17 +01:00
9a5c087940 Merge pull request 'compilation fixed' (#230) from mntx-229 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/discovery 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/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #230
2026-01-05 01:52:23 +00:00
Stephan D
4fb2e0433c compilation fixed 2026-01-05 02:51:39 +01:00
cd89171cf0 Merge pull request 'interface refactoring' (#228) from gateway-227 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/nats Pipeline was 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/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #228
2026-01-05 00:23:43 +00:00
Stephan D
7424ef751c interface refactoring 2026-01-05 01:22:47 +01:00
fcd831902a Merge pull request 'nats-225' (#226) from nats-225 into main
All checks were successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #226
2026-01-04 13:50:07 +00:00
Stephan D
03f4988a99 fixed mntx build 2026-01-04 14:46:20 +01:00
Stephan D
5684a959f5 isolated NATS logic 2026-01-04 14:44:46 +01:00
94406f65cb Merge pull request 'fixed discovery grpc port' (#224) from tgsettle-223 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/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #224
2026-01-04 13:00:08 +00:00
Stephan D
49ba144d8c fixed discovery grpc port 2026-01-04 13:59:39 +01:00
2c5f2b8cb1 Merge pull request 'tgsettle gateway build added' (#222) from tgsettle-221 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
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/discovery Pipeline failed
Reviewed-on: #222
2026-01-04 12:13:36 +00:00
Stephan D
ee28c13558 tgsettle gateway build added 2026-01-04 13:12:49 +01:00
6a57afc057 Merge pull request 'discovery-216' (#220) from discovery-216 into main
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/discovery Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #220
2026-01-04 11:59:01 +00:00
Stephan D
59c83e414a unified gateway interfaces 2026-01-04 12:57:40 +01:00
Stephan D
743f683d92 TG settlement service 2026-01-04 12:57:40 +01:00
Stephan D
ea1c69f14a discovery service 2026-01-04 12:57:40 +01:00
Stephan D
97ba7500dc unified gateway interface 2026-01-04 12:57:40 +01:00
19b7b69bd8 Merge pull request 'Refresh button for balance' (#219) from SEND022 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_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #219
2025-12-30 21:14:55 +00:00
Arseni
b157522fdb Refresh button for balance 2025-12-30 18:36:29 +03:00
202582626a Merge pull request 'fixed signature check' (#215) from signature-214 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_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
ci/woodpecker/push/mntx_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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #215
2025-12-29 23:23:27 +00:00
Stephan D
6a2efd3d22 fixed signature check 2025-12-30 00:22:49 +01:00
a6374d1136 Merge pull request 'increased payout timeout' (#213) from timeout-210 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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
Reviewed-on: #213
2025-12-29 15:43:48 +00:00
Stephan D
7c864dc304 increased payout timeout 2025-12-29 16:43:03 +01:00
4aeb06fd31 Merge pull request 'Payments listing method' (#209) from payments-208 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: #209
2025-12-29 13:29:46 +00:00
Stephan D
d1786dc5d9 Payments listing method 2025-12-29 14:27:32 +01:00
f5bf8cf6d0 Merge pull request 'Finally Fixed search field in payment page and cleaned up payment flow' (#204) from SEND020 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 failed
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: #204
2025-12-26 19:35:16 +00:00
7daa4ab027 Merge pull request 'temp restrictions removal + better request callback logging' (#205) from mntx-204 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/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator 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/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
Reviewed-on: #205
2025-12-26 18:18:57 +00:00
Stephan D
6f2309669b temp restrictions removal + better request callback logging 2025-12-26 19:18:35 +01:00
Arseni
e4847cd137 Finally Fixed search field in payment page and cleaned up payment flow 2025-12-26 20:37:59 +03:00
dbd06a4162 Merge pull request 'fixed address normalizatoin' (#203) from tron-202 into main
All checks were 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/billing_fees Pipeline was successful
ci/woodpecker/push/db 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: #203
2025-12-26 16:25:01 +00:00
Stephan D
1ec6cd8386 fixed address normalizatoin 2025-12-26 17:24:43 +01:00
6daf567baf Merge pull request 'improved FX logging' (#197) from fx-196 into main
All checks were 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/billing_fees 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/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #197
2025-12-26 14:15:01 +00:00
23a57e543d Merge pull request 'payment recipient data' (#199) from mntx-198 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend 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/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #199
2025-12-26 14:14:52 +00:00
Stephan D
8adfab94b5 payment recipient data 2025-12-26 15:14:31 +01:00
Stephan D
db488a31e8 improved FX logging 2025-12-26 14:25:18 +01:00
3836ff5ef3 Merge pull request 'Got rid of deprecated methods' (#191) from SEND019 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend 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/fx_ingestor Pipeline failed
Reviewed-on: #191
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-26 13:09:55 +00:00
aef5c99a22 Merge pull request 'wallet search fix' (#195) from chain-190 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: #195
2025-12-26 13:09:42 +00:00
Stephan D
be7c965234 wallet search fix 2025-12-26 14:09:16 +01:00
Arseni
63448ab267 got rid of deprecated methods 2025-12-26 15:04:41 +03:00
34a565d86d Merge pull request 'improved logging + autotests' (#189) from mntx-188 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: #189
2025-12-26 11:26:52 +00:00
Stephan D
171d90b3f7 improved logging + autotests 2025-12-26 12:26:28 +01:00
5191336a49 Merge pull request 'extended logging + wallet referencing improved' (#186) from tron-185 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/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: #186
2025-12-26 00:31:34 +00:00
Stephan D
48f64a722d extended logging + wallet referencing improved 2025-12-26 01:31:15 +01:00
bde453d106 Merge pull request 'fixed wallet fetcher + removed excessive logging' (#184) from tron-183 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: #184
2025-12-26 00:22:41 +00:00
Stephan D
3bb33b8895 fixed wallet fetcher 2025-12-26 01:21:16 +01:00
8ee092089f Merge pull request 'replaced evm function for tron' (#182) from tron-182 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #182
2025-12-25 23:53:46 +00:00
Stephan D
eca3d0d62e replaced evm function for tron 2025-12-26 00:53:25 +01:00
aba743406a Merge pull request 'temp extended request logging' (#181) from log-181 into main
All checks were successful
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 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: #181
2025-12-25 21:36:19 +00:00
Stephan D
deb29efde3 temp extended request logging 2025-12-25 22:31:00 +01:00
6995afc47d Merge pull request 'extended logging' (#180) from log-180 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend 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/chain_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
Reviewed-on: #180
2025-12-25 21:04:18 +00:00
Stephan D
7b645a3bbe extended logging 2025-12-25 22:02:15 +01:00
0ddd92b88b Merge pull request 'extended logging' (#179) from log-179 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: #179
2025-12-25 20:50:05 +00:00
Stephan D
6151e3d3a5 extended logging 2025-12-25 21:49:44 +01:00
af7abbb095 Merge pull request 'extended logging' (#178) from log-178 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/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
Reviewed-on: #178
2025-12-25 20:01:55 +00:00
Stephan D
71be1ef9f0 extended logging 2025-12-25 21:01:37 +01:00
3df358d865 Merge pull request 'fixed trx source' (#177) from gas-176 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #177
2025-12-25 19:36:33 +00:00
Stephan D
c6b2ba486b fixed trx source 2025-12-25 20:35:57 +01:00
d324e455cc Merge pull request 'fixed self sending TRX issue' (#175) from gas-171 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/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/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
Reviewed-on: #175
2025-12-25 18:54:31 +00:00
Stephan D
8c87e5534e fixed self sending TRX issue 2025-12-25 19:54:01 +01:00
bcb3e9e647 Merge pull request 'added mntx client to payment orchestration' (#172) from mntx-170 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/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: #172
2025-12-25 17:23:58 +00:00
Stephan D
43f26143df added mntx client to payment orchestration 2025-12-25 18:23:08 +01:00
ed6e6bf1ba Merge pull request 'payment button connected + supported payment by quote reference' (#168) from pay-167 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: #168
2025-12-25 16:24:55 +00:00
Stephan D
2d38b974ba improved logging 2025-12-25 17:01:35 +01:00
Stephan D
610296b301 improved logging 2025-12-25 17:01:05 +01:00
Stephan D
fcc68c8380 payment button connected 2025-12-25 16:56:35 +01:00
b96babdfd4 Merge pull request 'new mntx funding wallet address' (#165) from mntx-164 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/nats 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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #165
2025-12-25 13:19:17 +00:00
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
Stephan D
d32b2aa959 new mntx funding wallet address 2025-12-25 14:18:18 +01: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
530 changed files with 32197 additions and 4474 deletions

3
.gitignore vendored
View File

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

72
.woodpecker/discovery.yml Normal file
View File

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

View File

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

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,12 +31,12 @@ 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
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect
@@ -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=
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -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

@@ -26,6 +26,7 @@ type Imp struct {
config *config config *config
app *grpcapp.App[storage.Repository] app *grpcapp.App[storage.Repository]
oracleClient oracleclient.Client oracleClient oracleclient.Client
service *fees.Service
} }
type config struct { type config struct {
@@ -65,6 +66,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
func (i *Imp) Shutdown() { func (i *Imp) Shutdown() {
if i.app == nil { if i.app == nil {
if i.service != nil {
i.service.Shutdown()
}
if i.oracleClient != nil { if i.oracleClient != nil {
_ = i.oracleClient.Close() _ = i.oracleClient.Close()
} }
@@ -76,6 +80,10 @@ func (i *Imp) Shutdown() {
timeout = i.config.Runtime.ShutdownTimeout() timeout = i.config.Runtime.ShutdownTimeout()
} }
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx) i.app.Shutdown(ctx)
cancel() cancel()
@@ -121,7 +129,9 @@ func (i *Imp) Start() error {
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, fees.WithOracleClient(oracleClient)) opts = append(opts, fees.WithOracleClient(oracleClient))
} }
return fees.NewService(logger, repo, producer, opts...), nil svc := fees.NewService(logger, repo, producer, opts...)
i.service = svc
return svc, nil
} }
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory) app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)

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

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/tech/sendico/billing/fees/internal/appversion"
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator" internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver" "github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
"github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage"
@@ -15,9 +16,11 @@ import (
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging" msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"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"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
@@ -36,6 +39,7 @@ type Service struct {
calculator Calculator calculator Calculator
oracle oracleclient.Client oracle oracleclient.Client
resolver FeeResolver resolver FeeResolver
announcer *discovery.Announcer
feesv1.UnimplementedFeeEngineServer feesv1.UnimplementedFeeEngineServer
} }
@@ -62,6 +66,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.resolver = resolver.New(repo.Plans(), svc.logger) svc.resolver = resolver.New(repo.Plans(), svc.logger)
} }
svc.startDiscoveryAnnouncer()
return svc return svc
} }
@@ -71,27 +77,80 @@ func (s *Service) Register(router routers.GRPC) error {
}) })
} }
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_FEES",
Operations: []string{"fee.calc"},
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
s.announcer.Start()
}
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 +171,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 +232,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 +247,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 +258,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 +275,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 +299,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 +329,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 +415,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 +433,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 +442,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 +458,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.

3
api/discovery/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
app

17
api/discovery/config.yml Normal file
View File

@@ -0,0 +1,17 @@
runtime:
shutdown_timeout_seconds: 15
metrics:
address: ":9405"
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Discovery Service
max_reconnects: 10
reconnect_wait: 5

51
api/discovery/go.mod Normal file
View File

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

225
api/discovery/go.sum Normal file
View File

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

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "Sendico Discovery Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,47 @@
package serverimp
import (
"os"
"strings"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
const defaultMetricsAddress = ":9405"
type config struct {
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
Messaging *msg.Config `yaml:"messaging"`
Metrics *metricsConfig `yaml:"metrics"`
}
type metricsConfig struct {
Address string `yaml:"address"`
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
cfg.Metrics.Address = defaultMetricsAddress
}
return cfg, nil
}

View File

@@ -0,0 +1,58 @@
package serverimp
import (
"github.com/tech/sendico/discovery/internal/appversion"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func (i *Imp) startDiscovery(cfg *config) error {
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
}
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
if err != nil {
return err
}
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry()
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery))
if err != nil {
return err
}
svc.Start()
i.registrySvc = svc
announce := discovery.Announcement{
Service: "DISCOVERY",
InstanceID: discovery.InstanceID(),
Operations: []string{"discovery.lookup"},
Version: appversion.Create().Short(),
}
i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce)
i.announcer.Start()
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
return nil
}
func (i *Imp) stopDiscovery() {
if i == nil {
return
}
if i.announcer != nil {
i.announcer.Stop()
i.announcer = nil
}
if i.registrySvc != nil {
i.registrySvc.Stop()
i.registrySvc = nil
}
}

View File

@@ -0,0 +1,85 @@
package serverimp
import (
"context"
"errors"
"net"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"go.uber.org/zap"
)
func (i *Imp) startMetrics(cfg *metricsConfig) {
if i == nil {
return
}
address := ""
if cfg != nil {
address = strings.TrimSpace(cfg.Address)
}
if address == "" {
i.logger.Info("Metrics endpoint disabled")
return
}
listener, err := net.Listen("tcp", address)
if err != nil {
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
return
}
router := chi.NewRouter()
router.Handle("/metrics", promhttp.Handler())
var healthRouter routers.Health
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
i.logger.Warn("Failed to initialise health router", zap.Error(err))
} else {
hr.SetStatus(health.SSStarting)
healthRouter = hr
}
i.metricsHealth = healthRouter
i.metricsSrv = &http.Server{
Addr: address,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
if healthRouter != nil {
healthRouter.SetStatus(health.SSRunning)
}
go func() {
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating)
}
}
}()
}
func (i *Imp) shutdownMetrics(ctx context.Context) {
if i.metricsHealth != nil {
i.metricsHealth.SetStatus(health.SSTerminating)
i.metricsHealth.Finish()
i.metricsHealth = nil
}
if i.metricsSrv == nil {
return
}
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
} else {
i.logger.Info("Metrics server stopped")
}
i.metricsSrv = nil
}

View File

@@ -0,0 +1,109 @@
package serverimp
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Start() error {
i.initStopChannels()
defer i.closeDone()
i.logger.Info("Starting discovery service", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
messagingDriver := "none"
if cfg.Messaging != nil {
messagingDriver = string(cfg.Messaging.Driver)
}
metricsAddress := ""
if cfg.Metrics != nil {
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
}
if metricsAddress == "" {
metricsAddress = "disabled"
}
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
i.startMetrics(cfg.Metrics)
if err := i.startDiscovery(cfg); err != nil {
i.stopDiscovery()
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
i.shutdownMetrics(ctx)
cancel()
return err
}
i.logger.Info("Discovery service ready", zap.String("messaging_driver", messagingDriver))
<-i.stopCh
i.logger.Info("Discovery service stop signal received")
return nil
}
func (i *Imp) Shutdown() {
timeout := i.shutdownTimeout()
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
i.stopDiscovery()
i.signalStop()
if i.doneCh != nil {
<-i.doneCh
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.shutdownMetrics(ctx)
cancel()
i.logger.Info("Discovery service stopped")
}
func (i *Imp) initStopChannels() {
if i.stopCh == nil {
i.stopCh = make(chan struct{})
}
if i.doneCh == nil {
i.doneCh = make(chan struct{})
}
}
func (i *Imp) signalStop() {
i.stopOnce.Do(func() {
if i.stopCh != nil {
close(i.stopCh)
}
})
}
func (i *Imp) closeDone() {
i.doneOnce.Do(func() {
if i.doneCh != nil {
close(i.doneCh)
}
})
}
func (i *Imp) shutdownTimeout() time.Duration {
if i.config != nil && i.config.Runtime != nil {
return i.config.Runtime.ShutdownTimeout()
}
return 15 * time.Second
}

View File

@@ -0,0 +1,28 @@
package serverimp
import (
"net/http"
"sync"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
registrySvc *discovery.RegistryService
announcer *discovery.Announcer
metricsSrv *http.Server
metricsHealth routers.Health
stopOnce sync.Once
doneOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/discovery/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

17
api/discovery/main.go Normal file
View File

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

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"
@@ -43,6 +49,18 @@ metrics:
enabled: true enabled: true
address: ":9102" address: ":9102"
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: FX Ingestor
max_reconnects: 10
reconnect_wait: 5
database: database:
driver: mongodb driver: mongodb
settings: settings:

View File

@@ -32,11 +32,11 @@ 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
github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // 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=
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -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

@@ -12,7 +12,10 @@ import (
mongostorage "github.com/tech/sendico/fx/storage/mongo" mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/health" "github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -68,6 +71,24 @@ func (a *App) Run(ctx context.Context) error {
return err return err
} }
var announcer *discovery.Announcer
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
if err != nil {
a.logger.Warn("Failed to initialize discovery broker", zap.Error(err))
} else {
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
announce := discovery.Announcement{
Service: "FX_INGESTOR",
Operations: []string{"fx.ingest"},
Version: appversion.Create().Short(),
}
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
announcer.Start()
defer announcer.Stop()
}
}
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info())) a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
metricsSrv.SetStatus(health.SSRunning) metricsSrv.SetStatus(health.SSRunning)

View File

@@ -8,16 +8,18 @@ import (
mmodel "github.com/tech/sendico/fx/ingestor/internal/model" mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const defaultPollInterval = 30 * time.Second const defaultPollInterval = 30 * time.Second
type Config struct { type Config struct {
PollIntervalSeconds int `yaml:"poll_interval_seconds"` PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Market MarketConfig `yaml:"market"` Market MarketConfig `yaml:"market"`
Database *db.Config `yaml:"database"` Database *db.Config `yaml:"database"`
Metrics *MetricsConfig `yaml:"metrics"` Metrics *MetricsConfig `yaml:"metrics"`
Messaging *messaging.Config `yaml:"messaging"`
pairs []Pair pairs []Pair
pairsBySource map[mmodel.Driver][]PairConfig pairsBySource map[mmodel.Driver][]PairConfig

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

@@ -11,6 +11,7 @@ import (
"github.com/tech/sendico/fx/ingestor/internal/app" "github.com/tech/sendico/fx/ingestor/internal/app"
"github.com/tech/sendico/fx/ingestor/internal/appversion" "github.com/tech/sendico/fx/ingestor/internal/appversion"
"github.com/tech/sendico/fx/ingestor/internal/signalctx" "github.com/tech/sendico/fx/ingestor/internal/signalctx"
"github.com/tech/sendico/pkg/discovery"
lf "github.com/tech/sendico/pkg/mlogger/factory" lf "github.com/tech/sendico/pkg/mlogger/factory"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -25,6 +26,7 @@ func main() {
flag.Parse() flag.Parse()
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor") logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync() defer logger.Sync()
av := appversion.Create() av := appversion.Create()
@@ -40,15 +42,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,11 +33,11 @@ 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
github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // 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=
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -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

@@ -22,8 +22,9 @@ type Imp struct {
file string file string
debug bool debug bool
config *grpcapp.Config config *grpcapp.Config
app *grpcapp.App[storage.Repository] app *grpcapp.App[storage.Repository]
service *oracle.Service
} }
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
@@ -38,6 +39,9 @@ func (i *Imp) Shutdown() {
if i.app == nil { if i.app == nil {
return return
} }
if i.service != nil {
i.service.Shutdown()
}
timeout := 15 * time.Second timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil { if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout() timeout = i.config.Runtime.ShutdownTimeout()
@@ -59,10 +63,12 @@ func (i *Imp) Start() error {
} }
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) {
return oracle.NewService(logger, repo, producer), nil svc := oracle.NewService(logger, repo, producer)
i.service = svc
return svc, nil
} }
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory) app, err := grpcapp.NewApp(i.logger, "fx", cfg, i.debug, repoFactory, serviceFactory)
if err != nil { if err != nil {
return err return err
} }

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

@@ -6,10 +6,12 @@ import (
"strings" "strings"
"time" "time"
"github.com/tech/sendico/fx/oracle/internal/appversion"
"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/api/routers" "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pmessaging "github.com/tech/sendico/pkg/messaging" pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
@@ -36,19 +38,22 @@ var (
) )
type Service struct { type Service struct {
logger mlogger.Logger logger mlogger.Logger
storage storage.Repository storage storage.Repository
producer pmessaging.Producer producer pmessaging.Producer
announcer *discovery.Announcer
oraclev1.UnimplementedOracleServer oraclev1.UnimplementedOracleServer
} }
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service { func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
initMetrics() initMetrics()
return &Service{ svc := &Service{
logger: logger.Named("oracle"), logger: logger.Named("oracle"),
storage: repo, storage: repo,
producer: prod, producer: prod,
} }
svc.startDiscoveryAnnouncer()
return svc
} }
func (s *Service) Register(router routers.GRPC) error { func (s *Service) Register(router routers.GRPC) error {
@@ -57,6 +62,28 @@ func (s *Service) Register(router routers.GRPC) error {
}) })
} }
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "FX_ORACLE",
Operations: []string{"fx.quote"},
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FXOracle), announce)
s.announcer.Start()
}
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) { func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
start := time.Now() start := time.Now()
responder := s.getQuoteResponder(ctx, req) responder := s.getQuoteResponder(ctx, req)
@@ -101,22 +128,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 +157,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 +177,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 +189,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 +235,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 +256,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 +282,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 +304,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 +316,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 +362,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 +370,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 +393,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 +413,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 +425,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 +433,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 +452,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

@@ -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

@@ -50,28 +50,28 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
defer cancel() defer cancel()
if err := s.Ping(ctx); err != nil { if err := s.Ping(ctx); err != nil {
s.logger.Error("mongo ping failed during store init", zap.Error(err)) s.logger.Error("Mongo ping failed during store init", zap.Error(err))
return nil, err return nil, err
} }
ratesStore, err := store.NewRates(s.logger, db) ratesStore, err := store.NewRates(s.logger, db)
if err != nil { if err != nil {
s.logger.Error("failed to initialize rates store", zap.Error(err)) s.logger.Error("Failed to initialize rates store", zap.Error(err))
return nil, err return nil, err
} }
quotesStore, err := store.NewQuotes(s.logger, db, txFactory) quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
if err != nil { if err != nil {
s.logger.Error("failed to initialize quotes store", zap.Error(err)) s.logger.Error("Failed to initialize quotes store", zap.Error(err))
return nil, err return nil, err
} }
pairsStore, err := store.NewPair(s.logger, db) pairsStore, err := store.NewPair(s.logger, db)
if err != nil { if err != nil {
s.logger.Error("failed to initialize pair store", zap.Error(err)) s.logger.Error("Failed to initialize pair store", zap.Error(err))
return nil, err return nil, err
} }
currencyStore, err := store.NewCurrency(s.logger, db) currencyStore, err := store.NewCurrency(s.logger, db)
if err != nil { if err != nil {
s.logger.Error("failed to initialize currency store", zap.Error(err)) s.logger.Error("Failed to initialize currency store", zap.Error(err))
return nil, err return nil, err
} }
@@ -80,7 +80,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
s.pairs = pairsStore s.pairs = pairsStore
s.currencies = currencyStore s.currencies = currencyStore
s.logger.Info("mongo storage ready") s.logger.Info("Mongo storage ready")
return s, nil return s, nil
} }

View File

@@ -29,11 +29,11 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
Unique: true, Unique: true,
} }
if err := repo.CreateIndex(index); err != nil { if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure currencies index", zap.Error(err)) logger.Error("Failed to ensure currencies index", zap.Error(err))
return nil, err return nil, err
} }
childLogger := logger.Named(model.CurrenciesCollection) childLogger := logger.Named(model.CurrenciesCollection)
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection)) childLogger.Debug("Currency store initialised", zap.String("collection", model.CurrenciesCollection))
return &currencyStore{ return &currencyStore{
logger: childLogger, logger: childLogger,
@@ -43,17 +43,17 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) { func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
if code == "" { if code == "" {
c.logger.Warn("attempt to fetch currency with empty code") c.logger.Warn("Attempt to fetch currency with empty code")
return nil, merrors.InvalidArgument("currencyStore: empty code") return nil, merrors.InvalidArgument("currencyStore: empty code")
} }
result := &model.Currency{} result := &model.Currency{}
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil { if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("currency not found", zap.String("code", code)) c.logger.Debug("Currency not found", zap.String("code", code))
} }
return nil, err return nil, err
} }
c.logger.Debug("currency loaded", zap.String("code", code)) c.logger.Debug("Currency loaded", zap.String("code", code))
return result, nil return result, nil
} }
@@ -77,20 +77,20 @@ func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Cur
return nil return nil
}) })
if err != nil { if err != nil {
c.logger.Error("failed to list currencies", zap.Error(err)) c.logger.Warn("Failed to list currencies", zap.Error(err))
return nil, err return nil, err
} }
c.logger.Debug("listed currencies", zap.Int("count", len(currencies))) c.logger.Debug("Listed currencies", zap.Int("count", len(currencies)))
return currencies, nil return currencies, nil
} }
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error { func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
if currency == nil { if currency == nil {
c.logger.Warn("attempt to upsert nil currency") c.logger.Warn("Attempt to upsert nil currency")
return merrors.InvalidArgument("currencyStore: nil currency") return merrors.InvalidArgument("currencyStore: nil currency")
} }
if currency.Code == "" { if currency.Code == "" {
c.logger.Warn("attempt to upsert currency with empty code") c.logger.Warn("Attempt to upsert currency with empty code")
return merrors.InvalidArgument("currencyStore: empty code") return merrors.InvalidArgument("currencyStore: empty code")
} }
@@ -98,16 +98,16 @@ func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) er
filter := repository.Filter("code", currency.Code) filter := repository.Filter("code", currency.Code)
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil { if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("inserting new currency", zap.String("code", currency.Code)) c.logger.Debug("Inserting new currency", zap.String("code", currency.Code))
return c.repo.Insert(ctx, currency, filter) return c.repo.Insert(ctx, currency, filter)
} }
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code)) c.logger.Warn("Failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
return err return err
} }
if existing.GetID() != nil { if existing.GetID() != nil {
currency.SetID(*existing.GetID()) currency.SetID(*existing.GetID())
} }
c.logger.Debug("updating currency", zap.String("code", currency.Code)) c.logger.Debug("Updating currency", zap.String("code", currency.Code))
return c.repo.Update(ctx, currency) return c.repo.Update(ctx, currency)
} }

View File

@@ -29,10 +29,10 @@ func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, erro
Unique: true, Unique: true,
} }
if err := repo.CreateIndex(index); err != nil { if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure pairs index", zap.Error(err)) logger.Error("Failed to ensure pairs index", zap.Error(err))
return nil, err return nil, err
} }
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection)) logger.Debug("Pair store initialised", zap.String("collection", model.PairsCollection))
return &pairStore{ return &pairStore{
logger: logger.Named(model.PairsCollection), logger: logger.Named(model.PairsCollection),
@@ -53,16 +53,16 @@ func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
return nil return nil
}) })
if err != nil { if err != nil {
p.logger.Error("failed to list enabled pairs", zap.Error(err)) p.logger.Warn("Failed to list enabled pairs", zap.Error(err))
return nil, err return nil, err
} }
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs))) p.logger.Debug("Listed enabled pairs", zap.Int("count", len(pairs)))
return pairs, nil return pairs, nil
} }
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) { func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair.Base == "" || pair.Quote == "" { if pair.Base == "" || pair.Quote == "" {
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote)) p.logger.Warn("Attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return nil, merrors.InvalidArgument("pairStore: incomplete pair") return nil, merrors.InvalidArgument("pairStore: incomplete pair")
} }
result := &model.Pair{} result := &model.Pair{}
@@ -71,21 +71,21 @@ func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pa
Filter(repository.Field("pair").Dot("quote"), pair.Quote) Filter(repository.Field("pair").Dot("quote"), pair.Quote)
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil { if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote)) p.logger.Debug("Pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
} }
return nil, err return nil, err
} }
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote)) p.logger.Debug("Pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return result, nil return result, nil
} }
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error { func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
if pair == nil { if pair == nil {
p.logger.Warn("attempt to upsert nil pair") p.logger.Warn("Attempt to upsert nil pair")
return merrors.InvalidArgument("pairStore: nil pair") return merrors.InvalidArgument("pairStore: nil pair")
} }
if pair.Pair.Base == "" || pair.Pair.Quote == "" { if pair.Pair.Base == "" || pair.Pair.Quote == "" {
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Warn("Attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return merrors.InvalidArgument("pairStore: incomplete pair") return merrors.InvalidArgument("pairStore: incomplete pair")
} }
@@ -96,16 +96,16 @@ func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
err := p.repo.FindOneByFilter(ctx, query, existing) err := p.repo.FindOneByFilter(ctx, query, existing)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Debug("Inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Insert(ctx, pair, query) return p.repo.Insert(ctx, pair, query)
} }
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Warn("Failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return err return err
} }
if existing.GetID() != nil { if existing.GetID() != nil {
pair.SetID(*existing.GetID()) pair.SetID(*existing.GetID())
} }
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote)) p.logger.Debug("Updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Update(ctx, pair) return p.repo.Update(ctx, pair)
} }

View File

@@ -56,12 +56,12 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
for _, def := range indexes { for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil { if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure quotes index", zap.Error(err)) logger.Error("Failed to ensure quotes index", zap.Error(err))
return nil, err return nil, err
} }
} }
childLogger := logger.Named(model.QuotesCollection) childLogger := logger.Named(model.QuotesCollection)
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection)) childLogger.Debug("Quotes store initialised", zap.String("collection", model.QuotesCollection))
return &quotesStore{ return &quotesStore{
logger: childLogger, logger: childLogger,
@@ -72,11 +72,11 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error { func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
if quote == nil { if quote == nil {
q.logger.Warn("attempt to issue nil quote") q.logger.Warn("Attempt to issue nil quote")
return merrors.InvalidArgument("quotesStore: nil quote") return merrors.InvalidArgument("quotesStore: nil quote")
} }
if quote.QuoteRef == "" { if quote.QuoteRef == "" {
q.logger.Warn("attempt to issue quote with empty ref") q.logger.Warn("Attempt to issue quote with empty ref")
return merrors.InvalidArgument("quotesStore: empty quoteRef") return merrors.InvalidArgument("quotesStore: empty quoteRef")
} }
@@ -89,32 +89,32 @@ func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
quote.ConsumedByLedgerTxnRef = "" quote.ConsumedByLedgerTxnRef = ""
quote.ConsumedAtUnixMs = nil quote.ConsumedAtUnixMs = nil
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil { if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef)) q.logger.Warn("Failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
return err return err
} }
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm)) q.logger.Debug("Quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
return nil return nil
} }
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) { func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
if quoteRef == "" { if quoteRef == "" {
q.logger.Warn("attempt to fetch quote with empty ref") q.logger.Warn("Attempt to fetch quote with empty ref")
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef") return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
} }
quote := &model.Quote{} quote := &model.Quote{}
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil { if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef)) q.logger.Debug("Quote not found", zap.String("quote_ref", quoteRef))
} }
return nil, err return nil, err
} }
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status))) q.logger.Debug("Quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
return quote, nil return quote, nil
} }
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) { func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
if quoteRef == "" || ledgerTxnRef == "" { if quoteRef == "" || ledgerTxnRef == "" {
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Warn("Attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, merrors.InvalidArgument("quotesStore: missing identifiers") return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
} }
@@ -122,7 +122,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
when = time.Now() when = time.Now()
} }
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Debug("Consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
txn := q.txFactory.CreateTransaction() txn := q.txFactory.CreateTransaction()
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) { result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
quote := &model.Quote{} quote := &model.Quote{}
@@ -131,7 +131,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
} }
if !quote.Firm { if !quote.Firm {
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef)) q.logger.Warn("Quote not firm", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteNotFirm return nil, storage.ErrQuoteNotFirm
} }
@@ -140,16 +140,16 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
if err := q.repo.Update(txCtx, quote); err != nil { if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err return nil, err
} }
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef)) q.logger.Info("Quote expired during consume", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteExpired return nil, storage.ErrQuoteExpired
} }
if quote.Status == model.QuoteStatusConsumed { if quote.Status == model.QuoteStatusConsumed {
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef { if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Debug("Quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil return quote, nil
} }
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef)) q.logger.Warn("Quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
return nil, storage.ErrQuoteConsumed return nil, storage.ErrQuoteConsumed
} }
@@ -157,11 +157,11 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
if err := q.repo.Update(txCtx, quote); err != nil { if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err return nil, err
} }
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Info("Quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil return quote, nil
}) })
if err != nil { if err != nil {
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef)) q.logger.Warn("Quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, err return nil, err
} }
quote, _ := result.(*model.Quote) quote, _ := result.(*model.Quote)
@@ -173,7 +173,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) { func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
if cutoff.IsZero() { if cutoff.IsZero() {
q.logger.Warn("attempt to expire quotes with zero cutoff") q.logger.Warn("Attempt to expire quotes with zero cutoff")
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero") return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
} }
@@ -188,11 +188,11 @@ func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time)
updated, err := q.repo.PatchMany(ctx, filter, patch) updated, err := q.repo.PatchMany(ctx, filter, patch)
if err != nil { if err != nil {
q.logger.Error("failed to expire quotes", zap.Error(err)) q.logger.Warn("Failed to expire quotes", zap.Error(err))
return 0, err return 0, err
} }
if updated > 0 { if updated > 0 {
q.logger.Info("quotes expired", zap.Int("count", updated)) q.logger.Info("Quotes expired", zap.Int("count", updated))
} }
return updated, nil return updated, nil
} }

View File

@@ -51,11 +51,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
for _, def := range indexes { for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil { if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure rates index", zap.Error(err)) logger.Error("Failed to ensure rates index", zap.Error(err))
return nil, err return nil, err
} }
} }
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection)) logger.Debug("Rates store initialised", zap.String("collection", model.RatesCollection))
return &ratesStore{ return &ratesStore{
logger: logger.Named(model.RatesCollection), logger: logger.Named(model.RatesCollection),
repo: repo, repo: repo,
@@ -64,11 +64,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error { func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
if snapshot == nil { if snapshot == nil {
r.logger.Warn("attempt to upsert nil snapshot") r.logger.Warn("Attempt to upsert nil snapshot")
return merrors.InvalidArgument("ratesStore: nil snapshot") return merrors.InvalidArgument("ratesStore: nil snapshot")
} }
if snapshot.RateRef == "" { if snapshot.RateRef == "" {
r.logger.Warn("attempt to upsert snapshot with empty rate_ref") r.logger.Warn("Attempt to upsert snapshot with empty rate_ref")
return merrors.InvalidArgument("ratesStore: empty rateRef") return merrors.InvalidArgument("ratesStore: empty rateRef")
} }
@@ -82,17 +82,17 @@ func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSna
err := r.repo.FindOneByFilter(ctx, filter, existing) err := r.repo.FindOneByFilter(ctx, filter, existing)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef)) r.logger.Debug("Inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Insert(ctx, snapshot, filter) return r.repo.Insert(ctx, snapshot, filter)
} }
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef)) r.logger.Warn("Failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
return err return err
} }
if existing.GetID() != nil { if existing.GetID() != nil {
snapshot.SetID(*existing.GetID()) snapshot.SetID(*existing.GetID())
} }
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef)) r.logger.Debug("Updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Update(ctx, snapshot) return r.repo.Update(ctx, snapshot)
} }

View File

@@ -8,12 +8,19 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
) )
const chainConnectorID = "chain"
// Client exposes typed helpers around the chain gateway gRPC API. // Client exposes typed helpers around the chain gateway gRPC API.
type Client interface { type Client interface {
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
@@ -24,24 +31,26 @@ 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
} }
type grpcGatewayClient interface { type grpcConnectorClient interface {
CreateManagedWallet(ctx context.Context, in *chainv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.CreateManagedWalletResponse, error) GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error)
GetManagedWallet(ctx context.Context, in *chainv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.GetManagedWalletResponse, error) OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error)
ListManagedWallets(ctx context.Context, in *chainv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*chainv1.ListManagedWalletsResponse, error) GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
GetWalletBalance(ctx context.Context, in *chainv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*chainv1.GetWalletBalanceResponse, error) ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
SubmitTransfer(ctx context.Context, in *chainv1.SubmitTransferRequest, opts ...grpc.CallOption) (*chainv1.SubmitTransferResponse, error) GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error) ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
} }
type chainGatewayClient struct { type chainGatewayClient struct {
cfg Config cfg Config
conn *grpc.ClientConn conn *grpc.ClientConn
client grpcGatewayClient client grpcConnectorClient
} }
// New dials the chain gateway endpoint and returns a ready client. // New dials the chain gateway endpoint and returns a ready client.
@@ -71,12 +80,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
return &chainGatewayClient{ return &chainGatewayClient{
cfg: cfg, cfg: cfg,
conn: conn, conn: conn,
client: chainv1.NewChainGatewayServiceClient(conn), client: connectorv1.NewConnectorServiceClient(conn),
}, nil }, nil
} }
// NewWithClient injects a pre-built gateway client (useful for tests). // NewWithClient injects a pre-built gateway client (useful for tests).
func NewWithClient(cfg Config, gc grpcGatewayClient) Client { func NewWithClient(cfg Config, gc grpcConnectorClient) Client {
cfg.setDefaults() cfg.setDefaults()
return &chainGatewayClient{ return &chainGatewayClient{
cfg: cfg, cfg: cfg,
@@ -94,49 +103,213 @@ func (c *chainGatewayClient) Close() error {
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.CreateManagedWallet(ctx, req)
params, err := walletParamsFromRequest(req)
if err != nil {
return nil, err
}
label := ""
if desc := req.GetDescribable(); desc != nil {
label = strings.TrimSpace(desc.GetName())
}
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
Asset: assetStringFromChainAsset(req.GetAsset()),
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Label: label,
Params: params,
})
if err != nil {
return nil, err
}
if resp.GetError() != nil {
return nil, connectorError(resp.GetError())
}
wallet := managedWalletFromAccount(resp.GetAccount())
return &chainv1.CreateManagedWalletResponse{Wallet: wallet}, nil
} }
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.GetManagedWallet(ctx, req) if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}})
if err != nil {
return nil, err
}
return &chainv1.GetManagedWalletResponse{Wallet: managedWalletFromAccount(resp.GetAccount())}, nil
} }
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) { func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.ListManagedWallets(ctx, req) assetString := ""
ownerRef := ""
var page *paginationv1.CursorPageRequest
if req != nil {
assetString = assetStringFromChainAsset(req.GetAsset())
ownerRef = strings.TrimSpace(req.GetOwnerRef())
page = req.GetPage()
}
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
OwnerRef: ownerRef,
Asset: assetString,
Page: page,
})
if err != nil {
return nil, err
}
wallets := make([]*chainv1.ManagedWallet, 0, len(resp.GetAccounts()))
for _, account := range resp.GetAccounts() {
wallets = append(wallets, managedWalletFromAccount(account))
}
return &chainv1.ListManagedWalletsResponse{Wallets: wallets, Page: resp.GetPage()}, nil
} }
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.GetWalletBalance(ctx, req) if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}})
if err != nil {
return nil, err
}
balance := resp.GetBalance()
if balance == nil {
return nil, merrors.Internal("chain-gateway: balance response missing")
}
return &chainv1.GetWalletBalanceResponse{Balance: &chainv1.WalletBalance{
Available: balance.GetAvailable(),
PendingInbound: balance.GetPendingInbound(),
PendingOutbound: balance.GetPendingOutbound(),
CalculatedAt: balance.GetCalculatedAt(),
}}, nil
} }
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.SubmitTransfer(ctx, req) if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
operation, err := operationFromTransfer(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
transfer := transferFromReceipt(req, resp.GetReceipt())
return &chainv1.SubmitTransferResponse{Transfer: transfer}, nil
} }
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) { func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.GetTransfer(ctx, req) if req == nil || strings.TrimSpace(req.GetTransferRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: transfer_ref is required")
}
resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetTransferRef())})
if err != nil {
return nil, err
}
return &chainv1.GetTransferResponse{Transfer: transferFromOperation(resp.GetOperation())}, nil
} }
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) { func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.ListTransfers(ctx, req) source := ""
status := chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
var page *paginationv1.CursorPageRequest
if req != nil {
source = strings.TrimSpace(req.GetSourceWalletRef())
status = req.GetStatus()
page = req.GetPage()
}
resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{
AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: source},
Status: operationStatusFromTransfer(status),
Page: page,
})
if err != nil {
return nil, err
}
transfers := make([]*chainv1.Transfer, 0, len(resp.GetOperations()))
for _, op := range resp.GetOperations() {
transfers = append(transfers, transferFromOperation(op))
}
return &chainv1.ListTransfersResponse{Transfers: transfers, Page: resp.GetPage()}, nil
} }
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()
return c.client.EstimateTransferFee(ctx, req) if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
operation, err := feeEstimateOperation(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return estimateFromReceipt(resp.GetReceipt()), nil
}
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
operation, err := gasTopUpComputeOperation(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return computeGasTopUpFromReceipt(resp.GetReceipt()), nil
}
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
operation, err := gasTopUpEnsureOperation(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return ensureGasTopUpFromReceipt(resp.GetReceipt()), nil
} }
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
@@ -146,3 +319,495 @@ func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context,
} }
return context.WithTimeout(ctx, timeout) return context.WithTimeout(ctx, timeout)
} }
func walletParamsFromRequest(req *chainv1.CreateManagedWalletRequest) (*structpb.Struct, error) {
if req == nil {
return nil, nil
}
params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
}
if asset := req.GetAsset(); asset != nil {
params["network"] = asset.GetChain().String()
params["token_symbol"] = strings.TrimSpace(asset.GetTokenSymbol())
params["contract_address"] = strings.TrimSpace(asset.GetContractAddress())
}
desc := ""
if describable := req.GetDescribable(); describable != nil {
desc = strings.TrimSpace(describable.GetDescription())
}
if desc != "" {
params["description"] = desc
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
return structpb.NewStruct(params)
}
func managedWalletFromAccount(account *connectorv1.Account) *chainv1.ManagedWallet {
if account == nil {
return nil
}
details := map[string]interface{}{}
if account.GetProviderDetails() != nil {
details = account.GetProviderDetails().AsMap()
}
walletRef := ""
if ref := account.GetRef(); ref != nil {
walletRef = strings.TrimSpace(ref.GetAccountId())
}
if v := stringFromDetails(details, "wallet_ref"); v != "" {
walletRef = v
}
organizationRef := stringFromDetails(details, "organization_ref")
ownerRef := stringFromDetails(details, "owner_ref")
if ownerRef == "" {
ownerRef = strings.TrimSpace(account.GetOwnerRef())
}
asset := &chainv1.Asset{
Chain: chainNetworkFromString(stringFromDetails(details, "network")),
TokenSymbol: strings.TrimSpace(stringFromDetails(details, "token_symbol")),
ContractAddress: strings.TrimSpace(stringFromDetails(details, "contract_address")),
}
if asset.GetTokenSymbol() == "" {
asset.TokenSymbol = strings.TrimSpace(tokenFromAssetString(account.GetAsset()))
}
return &chainv1.ManagedWallet{
WalletRef: walletRef,
OrganizationRef: organizationRef,
OwnerRef: ownerRef,
Asset: asset,
DepositAddress: stringFromDetails(details, "deposit_address"),
Status: managedWalletStatusFromAccount(account.GetState()),
CreatedAt: account.GetCreatedAt(),
UpdatedAt: account.GetUpdatedAt(),
Describable: &describablev1.Describable{
Name: strings.TrimSpace(account.GetLabel()),
},
}
}
func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
if strings.TrimSpace(req.GetIdempotencyKey()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required")
}
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
}
if req.GetDestination() == nil {
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
if req.GetAmount() == nil {
return nil, merrors.InvalidArgument("chain-gateway: amount is required")
}
params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
}
if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" {
params["destination_memo"] = memo
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
if len(req.GetFees()) > 0 {
params["fees"] = feesToInterface(req.GetFees())
}
op := &connectorv1.Operation{
Type: connectorv1.OperationType_TRANSFER,
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Money: req.GetAmount(),
Params: structFromMap(params),
}
to, err := destinationToParty(req.GetDestination())
if err != nil {
return nil, err
}
op.To = to
return op, nil
}
func destinationToParty(dest *chainv1.TransferDestination) (*connectorv1.OperationParty, error) {
if dest == nil {
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(d.ManagedWalletRef)}}}, nil
case *chainv1.TransferDestination_ExternalAddress:
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ExternalRef: strings.TrimSpace(d.ExternalAddress)}}}, nil
default:
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
}
func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv1.OperationReceipt) *chainv1.Transfer {
transfer := &chainv1.Transfer{}
if req != nil {
transfer.IdempotencyKey = strings.TrimSpace(req.GetIdempotencyKey())
transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
transfer.Destination = req.GetDestination()
transfer.RequestedAmount = req.GetAmount()
transfer.NetAmount = req.GetAmount()
}
if receipt != nil {
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
transfer.Status = transferStatusFromOperation(receipt.GetStatus())
transfer.TransactionHash = strings.TrimSpace(receipt.GetProviderRef())
}
return transfer
}
func transferFromOperation(op *connectorv1.Operation) *chainv1.Transfer {
if op == nil {
return nil
}
transfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(op.GetOperationId()),
IdempotencyKey: strings.TrimSpace(op.GetOperationId()),
RequestedAmount: op.GetMoney(),
NetAmount: op.GetMoney(),
Status: transferStatusFromOperation(op.GetStatus()),
TransactionHash: strings.TrimSpace(op.GetProviderRef()),
CreatedAt: op.GetCreatedAt(),
UpdatedAt: op.GetUpdatedAt(),
}
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
transfer.SourceWalletRef = strings.TrimSpace(from.GetAccount().GetAccountId())
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}
}
if external := to.GetExternal(); external != nil {
transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(external.GetExternalRef())}}
}
}
return transfer
}
func feeEstimateOperation(req *chainv1.EstimateTransferFeeRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
}
if req.GetDestination() == nil {
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
if req.GetAmount() == nil {
return nil, merrors.InvalidArgument("chain-gateway: amount is required")
}
params := map[string]interface{}{}
op := &connectorv1.Operation{
Type: connectorv1.OperationType_FEE_ESTIMATE,
IdempotencyKey: feeEstimateKey(req),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Money: req.GetAmount(),
Params: structFromMap(params),
}
to, err := destinationToParty(req.GetDestination())
if err != nil {
return nil, err
}
op.To = to
return op, nil
}
func estimateFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EstimateTransferFeeResponse {
resp := &chainv1.EstimateTransferFeeResponse{}
if receipt == nil || receipt.GetResult() == nil {
return resp
}
data := receipt.GetResult().AsMap()
if networkFee, ok := data["network_fee"].(map[string]interface{}); ok {
amount := strings.TrimSpace(fmt.Sprint(networkFee["amount"]))
currency := strings.TrimSpace(fmt.Sprint(networkFee["currency"]))
if amount != "" && currency != "" {
resp.NetworkFee = &moneyv1.Money{Amount: amount, Currency: currency}
}
}
if ctx, ok := data["estimation_context"].(string); ok {
resp.EstimationContext = strings.TrimSpace(ctx)
}
return resp
}
func gasTopUpComputeOperation(req *chainv1.ComputeGasTopUpRequest) (*connectorv1.Operation, error) {
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
fee := req.GetEstimatedTotalFee()
if fee == nil {
return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required")
}
params := map[string]interface{}{
"mode": "compute",
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
}
return &connectorv1.Operation{
Type: connectorv1.OperationType_GAS_TOPUP,
IdempotencyKey: fmt.Sprintf("gas_topup_compute:%s:%s", strings.TrimSpace(req.GetWalletRef()), strings.TrimSpace(fee.GetAmount())),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}}},
Params: structFromMap(params),
}, nil
}
func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
if strings.TrimSpace(req.GetIdempotencyKey()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required")
}
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
}
if strings.TrimSpace(req.GetTargetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: target_wallet_ref is required")
}
fee := req.GetEstimatedTotalFee()
if fee == nil {
return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required")
}
params := map[string]interface{}{
"mode": "ensure",
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
return &connectorv1.Operation{
Type: connectorv1.OperationType_GAS_TOPUP,
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Params: structFromMap(params),
}, nil
}
func computeGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.ComputeGasTopUpResponse {
resp := &chainv1.ComputeGasTopUpResponse{}
if receipt == nil || receipt.GetResult() == nil {
return resp
}
data := receipt.GetResult().AsMap()
if amount, ok := data["topup_amount"].(map[string]interface{}); ok {
resp.TopupAmount = &moneyv1.Money{
Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])),
Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])),
}
}
if capHit, ok := data["cap_hit"].(bool); ok {
resp.CapHit = capHit
}
return resp
}
func ensureGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EnsureGasTopUpResponse {
resp := &chainv1.EnsureGasTopUpResponse{}
if receipt == nil || receipt.GetResult() == nil {
return resp
}
data := receipt.GetResult().AsMap()
if amount, ok := data["topup_amount"].(map[string]interface{}); ok {
resp.TopupAmount = &moneyv1.Money{
Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])),
Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])),
}
}
if capHit, ok := data["cap_hit"].(bool); ok {
resp.CapHit = capHit
}
if transferRef, ok := data["transfer_ref"].(string); ok {
resp.Transfer = &chainv1.Transfer{TransferRef: strings.TrimSpace(transferRef)}
}
return resp
}
func feeEstimateKey(req *chainv1.EstimateTransferFeeRequest) string {
if req == nil || req.GetAmount() == nil {
return "fee_estimate"
}
return fmt.Sprintf("fee_estimate:%s:%s:%s", strings.TrimSpace(req.GetSourceWalletRef()), strings.TrimSpace(req.GetAmount().GetCurrency()), strings.TrimSpace(req.GetAmount().GetAmount()))
}
func connectorError(err *connectorv1.ConnectorError) error {
if err == nil {
return nil
}
msg := strings.TrimSpace(err.GetMessage())
switch err.GetCode() {
case connectorv1.ErrorCode_INVALID_PARAMS:
return merrors.InvalidArgument(msg)
case connectorv1.ErrorCode_NOT_FOUND:
return merrors.NoData(msg)
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
return merrors.NotImplemented(msg)
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
return merrors.Internal(msg)
default:
return merrors.Internal(msg)
}
}
func structFromMap(data map[string]interface{}) *structpb.Struct {
if len(data) == 0 {
return nil
}
result, err := structpb.NewStruct(data)
if err != nil {
return nil
}
return result
}
func mapStringToInterface(input map[string]string) map[string]interface{} {
if len(input) == 0 {
return nil
}
out := make(map[string]interface{}, len(input))
for k, v := range input {
out[k] = v
}
return out
}
func feesToInterface(fees []*chainv1.ServiceFeeBreakdown) []interface{} {
if len(fees) == 0 {
return nil
}
result := make([]interface{}, 0, len(fees))
for _, fee := range fees {
if fee == nil || fee.GetAmount() == nil {
continue
}
result = append(result, map[string]interface{}{
"fee_code": strings.TrimSpace(fee.GetFeeCode()),
"description": strings.TrimSpace(fee.GetDescription()),
"amount": strings.TrimSpace(fee.GetAmount().GetAmount()),
"currency": strings.TrimSpace(fee.GetAmount().GetCurrency()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func stringFromDetails(details map[string]interface{}, key string) string {
if details == nil {
return ""
}
if value, ok := details[key]; ok {
return strings.TrimSpace(fmt.Sprint(value))
}
return ""
}
func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.ManagedWalletStatus {
switch state {
case connectorv1.AccountState_ACCOUNT_ACTIVE:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
case connectorv1.AccountState_ACCOUNT_SUSPENDED:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
case connectorv1.AccountState_ACCOUNT_CLOSED:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
default:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
}
}
func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_PENDING
}
}
func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func assetStringFromChainAsset(asset *chainv1.Asset) string {
if asset == nil {
return ""
}
symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if symbol == "" {
return ""
}
suffix := chainAssetSuffix(asset.GetChain())
if suffix == "" {
return symbol
}
return symbol + "-" + suffix
}
func chainAssetSuffix(chain chainv1.ChainNetwork) string {
switch chain {
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
return "ETH"
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
return "ARB"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
return "TRC20"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
return "TRC20"
default:
return ""
}
}
func tokenFromAssetString(asset string) string {
if asset == "" {
return ""
}
if idx := strings.Index(asset, "-"); idx > 0 {
return asset[:idx]
}
return asset
}
func chainNetworkFromString(value string) chainv1.ChainNetwork {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
if val, ok := chainv1.ChainNetwork_value[value]; ok {
return chainv1.ChainNetwork(val)
}
if !strings.HasPrefix(value, "CHAIN_NETWORK_") {
value = "CHAIN_NETWORK_" + value
}
if val, ok := chainv1.ChainNetwork_value[value]; ok {
return chainv1.ChainNetwork(val)
}
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}

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

@@ -0,0 +1,258 @@
package client
import (
"context"
"strings"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// RailGatewayConfig defines metadata for the rail gateway adapter.
type RailGatewayConfig struct {
Rail string
Network string
Capabilities rail.RailCapabilities
}
type chainRailGateway struct {
client Client
rail string
network string
capabilities rail.RailCapabilities
}
// NewRailGateway wraps a chain gateway client into a rail gateway adapter.
func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway {
railName := strings.ToUpper(strings.TrimSpace(cfg.Rail))
if railName == "" {
railName = "CRYPTO"
}
return &chainRailGateway{
client: client,
rail: railName,
network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
capabilities: cfg.Capabilities,
}
}
func (g *chainRailGateway) Rail() string {
return g.rail
}
func (g *chainRailGateway) Network() string {
return g.network
}
func (g *chainRailGateway) Capabilities() rail.RailCapabilities {
return g.capabilities
}
func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
if g.client == nil {
return rail.RailResult{}, merrors.Internal("chain gateway: client is required")
}
orgRef := strings.TrimSpace(req.OrganizationRef)
if orgRef == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required")
}
source := strings.TrimSpace(req.FromAccountID)
if source == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required")
}
destRef := strings.TrimSpace(req.ToAccountID)
if destRef == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required")
}
currency := strings.TrimSpace(req.Currency)
amountValue := strings.TrimSpace(req.Amount)
if currency == "" || amountValue == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required")
}
reqNetwork := strings.TrimSpace(req.Network)
if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch")
}
if strings.TrimSpace(req.IdempotencyKey) == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required")
}
dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo))
if err != nil {
return rail.RailResult{}, err
}
fees := toServiceFees(req.Fees)
if len(fees) == 0 && req.Fee != nil {
if amt := moneyFromRail(req.Fee); amt != nil {
fees = []*chainv1.ServiceFeeBreakdown{{
FeeCode: "fee",
Amount: amt,
}}
}
}
resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
OrganizationRef: orgRef,
SourceWalletRef: source,
Destination: dest,
Amount: &moneyv1.Money{
Currency: currency,
Amount: amountValue,
},
Fees: fees,
Metadata: cloneMetadata(req.Metadata),
ClientReference: strings.TrimSpace(req.ClientReference),
})
if err != nil {
return rail.RailResult{}, err
}
if resp == nil || resp.GetTransfer() == nil {
return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response")
}
transfer := resp.GetTransfer()
return rail.RailResult{
ReferenceID: strings.TrimSpace(transfer.GetTransferRef()),
Status: statusFromTransfer(transfer.GetStatus()),
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
}, nil
}
func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) {
if g.client == nil {
return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required")
}
ref := strings.TrimSpace(referenceID)
if ref == "" {
return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required")
}
resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref})
if err != nil {
return rail.ObserveResult{}, err
}
if resp == nil || resp.GetTransfer() == nil {
return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response")
}
transfer := resp.GetTransfer()
return rail.ObserveResult{
ReferenceID: ref,
Status: statusFromTransfer(transfer.GetStatus()),
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
}, nil
}
func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) {
managed, err := g.isManagedWallet(ctx, destRef)
if err != nil {
return nil, err
}
if managed {
return &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef},
}, nil
}
return &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef},
Memo: memo,
}, nil
}
func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) {
resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
if err != nil {
if status.Code(err) == codes.NotFound {
return false, nil
}
return false, err
}
if resp == nil || resp.GetWallet() == nil {
return false, nil
}
return true, nil
}
func statusFromTransfer(status chainv1.TransferStatus) string {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return rail.TransferStatusSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return rail.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return rail.TransferStatusRejected
case chainv1.TransferStatus_TRANSFER_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
return rail.TransferStatusPending
default:
return rail.TransferStatusPending
}
}
func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown {
if len(fees) == 0 {
return nil
}
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees))
for _, fee := range fees {
amount := moneyFromRail(fee.Amount)
if amount == nil {
continue
}
result = append(result, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fee.FeeCode),
Amount: amount,
Description: strings.TrimSpace(fee.Description),
})
}
if len(result) == 0 {
return nil
}
return result
}
func moneyFromRail(m *rail.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
amount := strings.TrimSpace(m.GetAmount())
if currency == "" || amount == "" {
return nil
}
return &moneyv1.Money{
Currency: currency,
Amount: amount,
}
}
func railMoneyFromProto(m *moneyv1.Money) *rail.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
amount := strings.TrimSpace(m.GetAmount())
if currency == "" || amount == "" {
return nil
}
return &rail.Money{
Currency: currency,
Amount: amount,
}
}
func cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
result := make(map[string]string, len(input))
for key, value := range input {
result[key] = value
}
return result
}

View File

@@ -8,7 +8,7 @@ grpc:
enable_health: true enable_health: true
metrics: metrics:
address: ":9403" address: ":9406"
database: database:
driver: mongodb driver: mongodb
@@ -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-20260104020744-7268a54d0358 // 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,12 +60,12 @@ 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
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil v3.21.11+incompatible // 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-20260104020744-7268a54d0358 h1:B6uGMdZ4maUTJm+LYgBwEIDuJxgOUACw8K0Yg6jpNbY=
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-20260104020744-7268a54d0358/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=
@@ -239,8 +239,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
@@ -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

@@ -45,22 +45,22 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
} }
address := strings.TrimSpace(cfg.Address) address := strings.TrimSpace(cfg.Address)
if address == "" { if address == "" {
logger.Error("vault address missing") logger.Error("Vault address missing")
return nil, merrors.InvalidArgument("vault key manager: address is required") return nil, merrors.InvalidArgument("vault key manager: address is required")
} }
tokenEnv := strings.TrimSpace(cfg.TokenEnv) tokenEnv := strings.TrimSpace(cfg.TokenEnv)
if tokenEnv == "" { if tokenEnv == "" {
logger.Error("vault token env missing") logger.Error("Vault token env missing")
return nil, merrors.InvalidArgument("vault key manager: token_env is required") return nil, merrors.InvalidArgument("vault key manager: token_env is required")
} }
token := strings.TrimSpace(os.Getenv(tokenEnv)) token := strings.TrimSpace(os.Getenv(tokenEnv))
if token == "" { if token == "" {
logger.Error("vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
} }
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/") mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
if mountPath == "" { if mountPath == "" {
logger.Error("vault mount path missing") logger.Error("Vault mount path missing")
return nil, merrors.InvalidArgument("vault key manager: mount_path is required") return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
} }
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/") keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
@@ -73,7 +73,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
client, err := api.NewClient(clientCfg) client, err := api.NewClient(clientCfg)
if err != nil { if err != nil {
logger.Error("failed to create vault client", zap.Error(err)) logger.Error("Failed to create vault client", zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
} }
client.SetToken(token) client.SetToken(token)
@@ -94,17 +94,17 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault. // CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
if strings.TrimSpace(walletRef) == "" { if strings.TrimSpace(walletRef) == "" {
m.logger.Warn("walletRef missing for managed key creation", zap.String("network", network)) m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
return nil, merrors.InvalidArgument("vault key manager: walletRef is required") return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
} }
if strings.TrimSpace(network) == "" { if strings.TrimSpace(network) == "" {
m.logger.Warn("network missing for managed key creation", zap.String("wallet_ref", walletRef)) m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
return nil, merrors.InvalidArgument("vault key manager: network is required") return nil, merrors.InvalidArgument("vault key manager: network is required")
} }
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader) privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
if err != nil { if err != nil {
m.logger.Warn("failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
} }
privateKeyBytes := crypto.FromECDSA(privateKey) privateKeyBytes := crypto.FromECDSA(privateKey)
@@ -115,7 +115,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address) err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
if err != nil { if err != nil {
m.logger.Warn("failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
zeroBytes(privateKeyBytes) zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes) zeroBytes(publicKeyBytes)
return nil, err return nil, err
@@ -123,7 +123,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
zeroBytes(privateKeyBytes) zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes) zeroBytes(publicKeyBytes)
m.logger.Info("managed wallet key created", m.logger.Info("Managed wallet key created",
zap.String("wallet_ref", walletRef), zap.String("wallet_ref", walletRef),
zap.String("network", network), zap.String("network", network),
zap.String("address", strings.ToLower(address)), zap.String("address", strings.ToLower(address)),
@@ -158,43 +158,43 @@ func (m *Manager) buildKeyID(network, walletRef string) string {
// SignTransaction loads the key material from Vault and signs the transaction. // SignTransaction loads the key material from Vault and signs the transaction.
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
if strings.TrimSpace(keyID) == "" { if strings.TrimSpace(keyID) == "" {
m.logger.Warn("signing failed: empty key id") m.logger.Warn("Signing failed: empty key id")
return nil, merrors.InvalidArgument("vault key manager: keyID is required") return nil, merrors.InvalidArgument("vault key manager: keyID is required")
} }
if tx == nil { if tx == nil {
m.logger.Warn("signing failed: nil transaction", zap.String("key_id", keyID)) m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: transaction is nil") return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
} }
if chainID == nil { if chainID == nil {
m.logger.Warn("signing failed: nil chain id", zap.String("key_id", keyID)) m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
return nil, merrors.InvalidArgument("vault key manager: chainID is nil") return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
} }
material, err := m.loadKey(ctx, keyID) material, err := m.loadKey(ctx, keyID)
if err != nil { if err != nil {
m.logger.Warn("failed to load key material", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
return nil, err return nil, err
} }
keyBytes, err := hex.DecodeString(material.PrivateKey) keyBytes, err := hex.DecodeString(material.PrivateKey)
if err != nil { if err != nil {
m.logger.Warn("invalid key material", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error()) return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
} }
defer zeroBytes(keyBytes) defer zeroBytes(keyBytes)
privateKey, err := crypto.ToECDSA(keyBytes) privateKey, err := crypto.ToECDSA(keyBytes)
if err != nil { if err != nil {
m.logger.Warn("failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
} }
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
if err != nil { if err != nil {
m.logger.Warn("failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error()) return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
} }
m.logger.Info("transaction signed with managed key", m.logger.Info("Transaction signed with managed key",
zap.String("key_id", keyID), zap.String("key_id", keyID),
zap.String("network", material.Network), zap.String("network", material.Network),
zap.String("tx_hash", signed.Hash().Hex()), zap.String("tx_hash", signed.Hash().Hex()),
@@ -213,23 +213,23 @@ func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, erro
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/") secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
secret, err := m.store.Get(ctx, secretPath) secret, err := m.store.Get(ctx, secretPath)
if err != nil { if err != nil {
m.logger.Warn("failed to read secret", zap.String("path", secretPath), zap.Error(err)) m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error()) return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
} }
if secret == nil || secret.Data == nil { if secret == nil || secret.Data == nil {
m.logger.Warn("secret not found", zap.String("path", secretPath)) m.logger.Warn("Secret not found", zap.String("path", secretPath))
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found") return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
} }
getString := func(key string) (string, error) { getString := func(key string) (string, error) {
val, ok := secret.Data[key] val, ok := secret.Data[key]
if !ok { if !ok {
m.logger.Warn("secret missing field", zap.String("path", secretPath), zap.String("field", key)) m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key))
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key) return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
} }
str, ok := val.(string) str, ok := val.(string)
if !ok || strings.TrimSpace(str) == "" { if !ok || strings.TrimSpace(str) == "" {
m.logger.Warn("secret field invalid", zap.String("path", secretPath), zap.String("field", key)) m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key))
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key) return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
} }
return str, nil return str, nil

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,9 @@ type Imp struct {
config *config config *config
app *grpcapp.App[storage.Repository] app *grpcapp.App[storage.Repository]
rpcClients *rpcclient.Clients
service *gatewayservice.Service
} }
type config struct { type config struct {
@@ -41,11 +48,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 +69,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{
@@ -80,10 +101,17 @@ func (i *Imp) Shutdown() {
timeout = i.config.Runtime.ShutdownTimeout() timeout = i.config.Runtime.ShutdownTimeout()
} }
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
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,23 +126,39 @@ 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 svc := gatewayservice.NewService(logger, repo, producer, opts...)
i.service = svc
return svc, nil
} }
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory) app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
@@ -129,7 +173,7 @@ func (i *Imp) Start() error {
func (i *Imp) loadConfig() (*config, error) { func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file) data, err := os.ReadFile(i.file)
if err != nil { if err != nil {
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err return nil, err
} }
@@ -137,7 +181,7 @@ func (i *Imp) loadConfig() (*config, error) {
Config: &grpcapp.Config{}, Config: &grpcapp.Config{},
} }
if err := yaml.Unmarshal(data, cfg); err != nil { if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("failed to parse configuration", zap.Error(err)) i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err return nil, err
} }
@@ -157,22 +201,23 @@ 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) == "" {
logger.Warn("skipping unnamed chain configuration") logger.Warn("Skipping unnamed chain configuration")
continue continue
} }
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 {
symbol := strings.TrimSpace(token.Symbol) symbol := strings.TrimSpace(token.Symbol)
if symbol == "" { if symbol == "" {
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name)) logger.Warn("Skipping token with empty symbol", zap.String("chain", chain.Name))
continue continue
} }
addr := strings.TrimSpace(token.Contract) addr := strings.TrimSpace(token.Contract)
@@ -182,9 +227,9 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
} }
if addr == "" { if addr == "" {
if env != "" { if env != "" {
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name)) logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
} else { } else {
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name)) logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
} }
continue continue
} }
@@ -194,15 +239,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 {
@@ -215,13 +329,13 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
if address == "" { if address == "" {
if cfg.AddressEnv != "" { if cfg.AddressEnv != "" {
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv)) logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv))
} else { } else {
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain)) logger.Warn("Service wallet address not configured", zap.String("chain", cfg.Chain))
} }
} }
if privateKey == "" { if privateKey == "" {
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv)) logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
} }
return gatewayshared.ServiceWallet{ return gatewayshared.ServiceWallet{
@@ -235,7 +349,7 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver))) driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
if driver == "" { if driver == "" {
err := merrors.InvalidArgument("key management driver is not configured") err := merrors.InvalidArgument("key management driver is not configured")
logger.Error("key management driver missing") logger.Error("Key management driver missing")
return nil, err return nil, err
} }
@@ -244,19 +358,19 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
settings := vaultmanager.Config{} settings := vaultmanager.Config{}
if len(cfg.Settings) > 0 { if len(cfg.Settings) > 0 {
if err := mapstructure.Decode(cfg.Settings, &settings); err != nil { if err := mapstructure.Decode(cfg.Settings, &settings); err != nil {
logger.Error("failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings)) logger.Error("Failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error()) return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
} }
} }
manager, err := vaultmanager.New(logger, settings) manager, err := vaultmanager.New(logger, settings)
if err != nil { if err != nil {
logger.Error("failed to initialise vault key manager", zap.Error(err)) logger.Error("Failed to initialise vault key manager", zap.Error(err))
return nil, err return nil, err
} }
return manager, nil return manager, nil
default: default:
err := merrors.InvalidArgument("unsupported key management driver: " + driver) err := merrors.InvalidArgument("unsupported key management driver: " + driver)
logger.Error("unsupported key management driver", zap.String("driver", driver)) logger.Error("Unsupported key management driver", zap.String("driver", driver))
return nil, err return nil, err
} }
} }

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

@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
managedRef := strings.TrimSpace(dest.GetManagedWalletRef()) managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
external := strings.TrimSpace(dest.GetExternalAddress()) external := strings.TrimSpace(dest.GetExternalAddress())
if managedRef != "" && external != "" { if managedRef != "" && external != "" {
deps.Logger.Warn("both managed and external destination provided") deps.Logger.Warn("Both managed and external destination provided")
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address") return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
} }
if managedRef != "" { if managedRef != "" {
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef) wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
if err != nil { if err != nil {
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef)) deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
return model.TransferDestination{}, err return model.TransferDestination{}, err
} }
if !strings.EqualFold(wallet.Network, source.Network) { if !strings.EqualFold(wallet.Network, source.Network) {
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network)) deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch") return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
} }
if strings.TrimSpace(wallet.DepositAddress) == "" { if strings.TrimSpace(wallet.DepositAddress) == "" {
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef)) deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address") return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
} }
return model.TransferDestination{ return model.TransferDestination{
@@ -40,11 +40,26 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
}, nil }, nil
} }
if external == "" { if external == "" {
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()), ExternalAddressOriginal: external,
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,13 @@ 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/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"
) )
@@ -33,216 +24,94 @@ func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] { func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Empty request received")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
} }
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" { if sourceWalletRef == "" {
c.deps.Logger.Warn("source wallet ref missing") c.deps.Logger.Warn("Source wallet ref missing")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
} }
amount := req.GetAmount() amount := req.GetAmount()
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
c.deps.Logger.Warn("amount missing or incomplete") c.deps.Logger.Warn("Amount missing or incomplete")
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
} }
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef) sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef)) c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
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"))
}
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")) 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 {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef())) c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("invalid destination", zap.Error(err)) c.deps.Logger.Warn("Invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) 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) walletForFee := sourceWallet
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
copyWallet := *sourceWallet
copyWallet.ContractAddress = ""
copyWallet.TokenSymbol = nativeCurrency
walletForFee = &copyWallet
}
driverDeps := driver.Deps{
Logger: c.deps.Logger,
Registry: c.deps.Networks,
RPCTimeout: c.deps.RPCTimeout,
}
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
if err != nil { 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(walletForFee.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

@@ -22,25 +22,25 @@ func NewGetTransfer(deps Deps) *getTransferCommand {
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] { func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
transferRef := strings.TrimSpace(req.GetTransferRef()) transferRef := strings.TrimSpace(req.GetTransferRef())
if transferRef == "" { if transferRef == "" {
c.deps.Logger.Warn("transfer_ref missing") c.deps.Logger.Warn("Transfer_ref missing")
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required")) return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
} }
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef) transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef)) c.deps.Logger.Warn("Not found", zap.String("transfer_ref", transferRef))
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef)) c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)}) return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})

View File

@@ -23,7 +23,7 @@ func NewListTransfers(deps Deps) *listTransfersCommand {
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] { func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
filter := model.TransferFilter{} filter := model.TransferFilter{}
@@ -41,7 +41,7 @@ func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTra
result, err := c.deps.Storage.Transfers().List(ctx, filter) result, err := c.deps.Storage.Transfers().List(ctx, filter)
if err != nil { if err != nil {
c.deps.Logger.Warn("storage list failed", zap.Error(err)) c.deps.Logger.Warn("Storage list failed", zap.Error(err))
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

View File

@@ -25,94 +25,102 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] { func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" { if idempotencyKey == "" {
c.deps.Logger.Warn("missing idempotency key") c.deps.Logger.Warn("Missing idempotency key")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
} }
organizationRef := strings.TrimSpace(req.GetOrganizationRef()) organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" { if organizationRef == "" {
c.deps.Logger.Warn("missing organization ref") c.deps.Logger.Warn("Missing organization ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
} }
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
if sourceWalletRef == "" { if sourceWalletRef == "" {
c.deps.Logger.Warn("missing source wallet ref") c.deps.Logger.Warn("Missing source wallet ref")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
} }
amount := req.GetAmount() amount := req.GetAmount()
if amount == nil { if amount == nil {
c.deps.Logger.Warn("missing amount") c.deps.Logger.Warn("Missing amount")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
} }
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
if amountCurrency == "" { if amountCurrency == "" {
c.deps.Logger.Warn("missing amount currency") c.deps.Logger.Warn("Missing amount currency")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
} }
amountValue := strings.TrimSpace(amount.GetAmount()) amountValue := strings.TrimSpace(amount.GetAmount())
if amountValue == "" { if amountValue == "" {
c.deps.Logger.Warn("missing amount value") c.deps.Logger.Warn("Missing amount value")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
} }
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef) sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef)) c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) { if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef)) c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet")) 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"))
} }
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet) destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef())) c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("invalid destination", zap.Error(err)) c.deps.Logger.Warn("Invalid destination", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency) fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
if err != nil { if err != nil {
c.deps.Logger.Warn("fee conversion failed", zap.Error(err)) c.deps.Logger.Warn("Fee conversion failed", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
amountDec, err := decimal.NewFromString(amountValue) amountDec, err := decimal.NewFromString(amountValue)
if err != nil { if err != nil {
c.deps.Logger.Warn("invalid amount", zap.Error(err)) c.deps.Logger.Warn("Invalid amount", zap.Error(err))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
} }
netDec := amountDec.Sub(feeSum) netDec := amountDec.Sub(feeSum)
if netDec.IsNegative() { if netDec.IsNegative() {
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String())) c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
} }
netAmount := shared.CloneMoney(amount) netAmount := shared.CloneMoney(amount)
netAmount.Amount = netDec.String() netAmount.Amount = netDec.String()
effectiveTokenSymbol := sourceWallet.TokenSymbol
effectiveContractAddress := sourceWallet.ContractAddress
nativeCurrency := shared.NativeCurrency(networkCfg)
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
effectiveTokenSymbol = nativeCurrency
effectiveContractAddress = ""
}
transfer := &model.Transfer{ transfer := &model.Transfer{
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
TransferRef: shared.GenerateTransferRef(), TransferRef: shared.GenerateTransferRef(),
@@ -120,8 +128,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
SourceWalletRef: sourceWalletRef, SourceWalletRef: sourceWalletRef,
Destination: destination, Destination: destination,
Network: sourceWallet.Network, Network: sourceWallet.Network,
TokenSymbol: sourceWallet.TokenSymbol, TokenSymbol: effectiveTokenSymbol,
ContractAddress: sourceWallet.ContractAddress, ContractAddress: effectiveContractAddress,
RequestedAmount: shared.CloneMoney(amount), RequestedAmount: shared.CloneMoney(amount),
NetAmount: netAmount, NetAmount: netAmount,
Fees: fees, Fees: fees,
@@ -133,10 +141,10 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer) saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey)) c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)}) return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
} }
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

View File

@@ -29,41 +29,41 @@ func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] { func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
walletRef := strings.TrimSpace(req.GetWalletRef()) walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" { if walletRef == "" {
c.deps.Logger.Warn("wallet_ref missing") c.deps.Logger.Warn("Wallet_ref missing")
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
} }
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef) wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) 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)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Cached balance not found", zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr) return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
} }
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if c.isCachedBalanceStale(stored) { if c.isCachedBalanceStale(stored) {
c.deps.Logger.Warn("cached balance is stale", c.deps.Logger.Info("Cached balance is stale",
zap.String("wallet_ref", walletRef), zap.String("wallet_ref", walletRef),
zap.Time("calculated_at", stored.CalculatedAt), zap.Time("calculated_at", stored.CalculatedAt),
zap.Duration("ttl", c.cacheTTL()), zap.Duration("ttl", c.cacheTTL()),
@@ -74,39 +74,49 @@ 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"
@@ -24,78 +25,118 @@ func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] { func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" { if idempotencyKey == "" {
c.deps.Logger.Warn("missing idempotency key") c.deps.Logger.Warn("Missing idempotency key")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
} }
organizationRef := strings.TrimSpace(req.GetOrganizationRef()) organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" { if organizationRef == "" {
c.deps.Logger.Warn("missing organization ref") c.deps.Logger.Warn("Missing organization ref")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
} }
ownerRef := strings.TrimSpace(req.GetOwnerRef()) ownerRef := strings.TrimSpace(req.GetOwnerRef())
if ownerRef == "" { if ownerRef == "" {
c.deps.Logger.Warn("missing owner ref") c.deps.Logger.Warn("Missing owner ref")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
} }
asset := req.GetAsset() asset := req.GetAsset()
if asset == nil { if asset == nil {
c.deps.Logger.Warn("missing asset") c.deps.Logger.Warn("Missing asset")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
} }
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain()) chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
if chainKey == "" { if chainKey == "" {
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain())) c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) 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"))
}
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")) 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 == "" {
c.deps.Logger.Warn("missing token symbol") c.deps.Logger.Warn("Missing token symbol")
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
} }
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"))
}
} }
} }
walletRef := shared.GenerateWalletRef() walletRef := shared.GenerateWalletRef()
if c.deps.KeyManager == nil { if c.deps.KeyManager == nil {
c.deps.Logger.Warn("key manager missing") c.deps.Logger.Warn("Key manager missing")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
} }
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey) keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
if err != nil { if err != nil {
c.deps.Logger.Warn("key manager error", zap.Error(err)) c.deps.Logger.Warn("Key manager error", zap.Error(err))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" { if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
c.deps.Logger.Warn("key manager returned empty address") c.deps.Logger.Warn("Key manager returned empty address")
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) 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,19 +144,22 @@ 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)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey)) c.deps.Logger.Debug("Wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)}) return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
} }
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

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

@@ -22,25 +22,25 @@ func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] { func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
if req == nil { if req == nil {
c.deps.Logger.Warn("nil request") c.deps.Logger.Warn("Nil request")
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
} }
walletRef := strings.TrimSpace(req.GetWalletRef()) walletRef := strings.TrimSpace(req.GetWalletRef())
if walletRef == "" { if walletRef == "" {
c.deps.Logger.Warn("wallet_ref missing") c.deps.Logger.Warn("Wallet_ref missing")
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
} }
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef) wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
if err != nil { if err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)}) return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})

View File

@@ -23,7 +23,7 @@ func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] { func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
if err := c.deps.EnsureRepository(ctx); err != nil { if err := c.deps.EnsureRepository(ctx); err != nil {
c.deps.Logger.Warn("repository unavailable", zap.Error(err)) c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
filter := model.ManagedWalletFilter{} filter := model.ManagedWalletFilter{}
@@ -42,7 +42,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li
result, err := c.deps.Storage.Wallets().List(ctx, filter) result, err := c.deps.Storage.Wallets().List(ctx, filter)
if err != nil { if err != nil {
c.deps.Logger.Warn("storage list failed", zap.Error(err)) c.deps.Logger.Warn("Storage list failed", zap.Error(err))
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
} }

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,691 @@
package gateway
import (
"context"
"errors"
"fmt"
"strings"
"github.com/tech/sendico/gateway/chain/internal/appversion"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/structpb"
)
const chainConnectorID = "chain"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: chainConnectorID,
Version: appversion.Create().Short(),
SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_CHAIN_MANAGED_WALLET},
SupportedOperationTypes: []connectorv1.OperationType{
connectorv1.OperationType_TRANSFER,
connectorv1.OperationType_FEE_ESTIMATE,
connectorv1.OperationType_GAS_TOPUP,
},
OpenAccountParams: chainOpenAccountParams(),
OperationParams: chainOperationParams(),
},
}, nil
}
func (s *Service) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
if req == nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil
}
if req.GetKind() != connectorv1.AccountKind_CHAIN_MANAGED_WALLET {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil
}
reader := params.New(req.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
if orgRef == "" {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil
}
asset, err := parseChainAsset(strings.TrimSpace(req.GetAsset()), reader)
if err != nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil
}
resp, err := s.CreateManagedWallet(ctx, &chainv1.CreateManagedWalletRequest{
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
OrganizationRef: orgRef,
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Asset: asset,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
Describable: describableFromLabel(req.GetLabel(), reader.String("description")),
})
if err != nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil
}
return &connectorv1.OpenAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
}
func (s *Service) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required")
}
resp, err := s.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
if err != nil {
return nil, err
}
return &connectorv1.GetAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
}
func (s *Service) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
if req == nil {
return nil, merrors.InvalidArgument("list_accounts: request is required")
}
asset := (*chainv1.Asset)(nil)
if assetString := strings.TrimSpace(req.GetAsset()); assetString != "" {
parsed, err := parseChainAsset(assetString, params.New(nil))
if err != nil {
return nil, merrors.InvalidArgument(err.Error())
}
asset = parsed
}
resp, err := s.ListManagedWallets(ctx, &chainv1.ListManagedWalletsRequest{
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Asset: asset,
Page: req.GetPage(),
})
if err != nil {
return nil, err
}
accounts := make([]*connectorv1.Account, 0, len(resp.GetWallets()))
for _, wallet := range resp.GetWallets() {
accounts = append(accounts, chainWalletToAccount(wallet))
}
return &connectorv1.ListAccountsResponse{
Accounts: accounts,
Page: resp.GetPage(),
}, nil
}
func (s *Service) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required")
}
resp, err := s.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
if err != nil {
return nil, err
}
bal := resp.GetBalance()
return &connectorv1.GetBalanceResponse{
Balance: &connectorv1.Balance{
AccountRef: req.GetAccountRef(),
Available: bal.GetAvailable(),
PendingInbound: bal.GetPendingInbound(),
PendingOutbound: bal.GetPendingOutbound(),
CalculatedAt: bal.GetCalculatedAt(),
},
}, nil
}
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
if req == nil || req.GetOperation() == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
reader := params.New(op.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
source := operationAccountID(op.GetFrom())
if source == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "operation: from.account is required", op, "")}}, nil
}
switch op.GetType() {
case connectorv1.OperationType_TRANSFER:
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
}
amount = normalizeMoneyForChain(amount)
if orgRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: organization_ref is required", op, "")}}, nil
}
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
Destination: dest,
Amount: amount,
Fees: parseChainFees(reader),
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transfer := resp.GetTransfer()
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Status: chainTransferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
},
}, nil
case connectorv1.OperationType_FEE_ESTIMATE:
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "estimate: money is required", op, "")}}, nil
}
amount = normalizeMoneyForChain(amount)
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
resp, err := s.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: source,
Destination: dest,
Amount: amount,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
result := feeEstimateResult(resp)
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: result,
},
}, nil
case connectorv1.OperationType_GAS_TOPUP:
fee, err := parseMoneyFromMap(reader.Map("estimated_total_fee"))
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
fee = normalizeMoneyForChain(fee)
mode := strings.ToLower(strings.TrimSpace(reader.String("mode")))
if mode == "" {
mode = "compute"
}
switch mode {
case "compute":
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
resp, err := s.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: source,
EstimatedTotalFee: fee,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""),
},
}, nil
case "ensure":
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
if orgRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: organization_ref is required", op, "")}}, nil
}
target := strings.TrimSpace(reader.String("target_wallet_ref"))
if target == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: target_wallet_ref is required", op, "")}}, nil
}
resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
TargetWalletRef: target,
EstimatedTotalFee: fee,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transferRef := ""
if transfer := resp.GetTransfer(); transfer != nil {
transferRef = strings.TrimSpace(transfer.GetTransferRef())
}
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef),
},
}, nil
default:
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: invalid mode", op, "")}}, nil
}
default:
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
}
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
if req == nil {
return nil, merrors.InvalidArgument("list_operations: request is required")
}
source := ""
if req.GetAccountRef() != nil {
source = strings.TrimSpace(req.GetAccountRef().GetAccountId())
}
resp, err := s.ListTransfers(ctx, &chainv1.ListTransfersRequest{
SourceWalletRef: source,
Status: chainStatusFromOperation(req.GetStatus()),
Page: req.GetPage(),
})
if err != nil {
return nil, err
}
ops := make([]*connectorv1.Operation, 0, len(resp.GetTransfers()))
for _, transfer := range resp.GetTransfers() {
ops = append(ops, chainTransferToOperation(transfer))
}
return &connectorv1.ListOperationsResponse{Operations: ops, Page: resp.GetPage()}, nil
}
func chainOpenAccountParams() []*connectorv1.ParamSpec {
return []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the wallet."},
{Key: "network", Type: connectorv1.ParamType_STRING, Required: true, Description: "Blockchain network name."},
{Key: "token_symbol", Type: connectorv1.ParamType_STRING, Required: true, Description: "Token symbol (e.g., USDT)."},
{Key: "contract_address", Type: connectorv1.ParamType_STRING, Required: false, Description: "Token contract address override."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."},
{Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Wallet description."},
}
}
func chainOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."},
{Key: "destination_memo", Type: connectorv1.ParamType_STRING, Required: false, Description: "Destination memo/tag."},
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference id."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Transfer metadata."},
{Key: "fees", Type: connectorv1.ParamType_JSON, Required: false, Description: "Service fee breakdowns."},
}},
{OperationType: connectorv1.OperationType_FEE_ESTIMATE, Params: []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Estimate metadata."},
}},
{OperationType: connectorv1.OperationType_GAS_TOPUP, Params: []*connectorv1.ParamSpec{
{Key: "mode", Type: connectorv1.ParamType_STRING, Required: false, Description: "compute | ensure."},
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference (required for ensure)."},
{Key: "target_wallet_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Target wallet ref (ensure)."},
{Key: "estimated_total_fee", Type: connectorv1.ParamType_JSON, Required: true, Description: "Estimated total fee {amount,currency}."},
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Top-up metadata."},
}},
}
}
func chainWalletToAccount(wallet *chainv1.ManagedWallet) *connectorv1.Account {
if wallet == nil {
return nil
}
details, _ := structpb.NewStruct(map[string]interface{}{
"deposit_address": wallet.GetDepositAddress(),
"organization_ref": wallet.GetOrganizationRef(),
"owner_ref": wallet.GetOwnerRef(),
"network": wallet.GetAsset().GetChain().String(),
"token_symbol": wallet.GetAsset().GetTokenSymbol(),
"contract_address": wallet.GetAsset().GetContractAddress(),
"wallet_ref": wallet.GetWalletRef(),
})
return &connectorv1.Account{
Ref: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(wallet.GetWalletRef()),
},
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
Asset: assetStringFromChainAsset(wallet.GetAsset()),
State: chainWalletState(wallet.GetStatus()),
Label: strings.TrimSpace(wallet.GetDescribable().GetName()),
OwnerRef: strings.TrimSpace(wallet.GetOwnerRef()),
ProviderDetails: details,
CreatedAt: wallet.GetCreatedAt(),
UpdatedAt: wallet.GetUpdatedAt(),
}
}
func chainWalletState(status chainv1.ManagedWalletStatus) connectorv1.AccountState {
switch status {
case chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE:
return connectorv1.AccountState_ACCOUNT_ACTIVE
case chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED:
return connectorv1.AccountState_ACCOUNT_SUSPENDED
case chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED:
return connectorv1.AccountState_ACCOUNT_CLOSED
default:
return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED
}
}
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
if op == nil {
return nil, fmt.Errorf("transfer: operation is required")
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
}
if ext := to.GetExternal(); ext != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
}
}
return nil, fmt.Errorf("transfer: to.account or to.external is required")
}
func normalizeMoneyForChain(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return &moneyv1.Money{
Amount: strings.TrimSpace(m.GetAmount()),
Currency: currency,
}
}
func parseChainFees(reader params.Reader) []*chainv1.ServiceFeeBreakdown {
rawFees := reader.List("fees")
if len(rawFees) == 0 {
return nil
}
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(rawFees))
for _, item := range rawFees {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
if amount == "" || currency == "" {
continue
}
result = append(result, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fmt.Sprint(raw["fee_code"])),
Description: strings.TrimSpace(fmt.Sprint(raw["description"])),
Amount: &moneyv1.Money{Amount: amount, Currency: currency},
})
}
if len(result) == 0 {
return nil
}
return result
}
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
if raw == nil {
return nil, fmt.Errorf("money is required")
}
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
if amount == "" || currency == "" {
return nil, fmt.Errorf("money is required")
}
return &moneyv1.Money{
Amount: amount,
Currency: currency,
}, nil
}
func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Struct {
if resp == nil {
return nil
}
payload := map[string]interface{}{
"estimation_context": strings.TrimSpace(resp.GetEstimationContext()),
}
if fee := resp.GetNetworkFee(); fee != nil {
payload["network_fee"] = map[string]interface{}{
"amount": strings.TrimSpace(fee.GetAmount()),
"currency": strings.TrimSpace(fee.GetCurrency()),
}
}
result, err := structpb.NewStruct(payload)
if err != nil {
return nil
}
return result
}
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{
"cap_hit": capHit,
}
if amount != nil {
payload["topup_amount"] = map[string]interface{}{
"amount": strings.TrimSpace(amount.GetAmount()),
"currency": strings.TrimSpace(amount.GetCurrency()),
}
}
if strings.TrimSpace(transferRef) != "" {
payload["transfer_ref"] = strings.TrimSpace(transferRef)
}
result, err := structpb.NewStruct(payload)
if err != nil {
return nil
}
return result
}
func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
if transfer == nil {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
}}},
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(d.ManagedWalletRef),
}}}
case *chainv1.TransferDestination_ExternalAddress:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
ExternalRef: strings.TrimSpace(d.ExternalAddress),
}}}
}
}
return op
}
func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
default:
return connectorv1.OperationStatus_PENDING
}
}
func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}
func parseChainAsset(assetString string, reader params.Reader) (*chainv1.Asset, error) {
network := strings.TrimSpace(reader.String("network"))
token := strings.TrimSpace(reader.String("token_symbol"))
contract := strings.TrimSpace(reader.String("contract_address"))
if token == "" {
token = tokenFromAssetString(assetString)
}
if network == "" {
network = networkFromAssetString(assetString)
}
if token == "" {
return nil, fmt.Errorf("asset: token_symbol is required")
}
chain := shared.ChainEnumFromName(network)
if chain == chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED {
return nil, fmt.Errorf("asset: network is required")
}
return &chainv1.Asset{
Chain: chain,
TokenSymbol: strings.ToUpper(token),
ContractAddress: strings.ToLower(contract),
}, nil
}
func tokenFromAssetString(asset string) string {
if asset == "" {
return ""
}
if idx := strings.Index(asset, "-"); idx > 0 {
return asset[:idx]
}
return asset
}
func networkFromAssetString(asset string) string {
if asset == "" {
return ""
}
idx := strings.Index(asset, "-")
if idx < 0 {
return ""
}
return strings.TrimSpace(asset[idx+1:])
}
func assetStringFromChainAsset(asset *chainv1.Asset) string {
if asset == nil {
return ""
}
symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if symbol == "" {
return ""
}
suffix := chainAssetSuffix(asset.GetChain())
if suffix == "" {
return symbol
}
return symbol + "-" + suffix
}
func chainAssetSuffix(chain chainv1.ChainNetwork) string {
switch chain {
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
return "ETH"
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
return "ARB"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
return "TRC20"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
return "TRC20"
default:
return ""
}
}
func describableFromLabel(label, desc string) *describablev1.Describable {
label = strings.TrimSpace(label)
desc = strings.TrimSpace(desc)
if label == "" && desc == "" {
return nil
}
return &describablev1.Describable{
Name: label,
Description: &desc,
}
}
func operationAccountID(party *connectorv1.OperationParty) string {
if party == nil {
return ""
}
if account := party.GetAccount(); account != nil {
return strings.TrimSpace(account.GetAccountId())
}
return ""
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,
Message: strings.TrimSpace(message),
AccountId: strings.TrimSpace(accountID),
}
if op != nil {
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
err.OperationId = strings.TrimSpace(op.GetOperationId())
}
return err
}
func mapErrorCode(err error) connectorv1.ErrorCode {
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return connectorv1.ErrorCode_INVALID_PARAMS
case errors.Is(err, merrors.ErrNoData):
return connectorv1.ErrorCode_NOT_FOUND
case errors.Is(err, merrors.ErrNotImplemented):
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
case errors.Is(err, merrors.ErrInternal):
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
default:
return connectorv1.ErrorCode_PROVIDER_ERROR
}
}

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,31 @@
package evm
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)
func TestTronEstimateCallUsesData(t *testing.T) {
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
callMsg := ethereum.CallMsg{
From: from,
To: &to,
GasPrice: big.NewInt(100),
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
}
call := tronEstimateCall(callMsg)
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
require.Equal(t, "0x64", call["gasPrice"])
require.Equal(t, "0xa9059cbb", call["data"])
_, hasInput := call["input"]
require.False(t, hasInput)
}

View File

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

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

View File

@@ -0,0 +1,33 @@
package tron
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
logger := zap.NewNop()
d := New(logger)
wallet := &model.ManagedWallet{
WalletRef: "wallet_ref",
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
}
network := shared.Network{
Name: "tron_mainnet",
NativeToken: "TRX",
}
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
require.NoError(t, err)
require.NotNil(t, fee)
require.Equal(t, "TRX", fee.GetCurrency())
require.Equal(t, "0", fee.GetAmount())
}

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,49 +42,52 @@ 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)
} }
o.logger.Info("submitting transfer", o.logger.Info("Submitting transfer",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef), zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name), zap.String("network", network.Name),
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 { if err != nil {
o.logger.Warn("failed to initialise rpc client", o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
return "", err
}
rpcClient, err := o.clients.RPCClient(network.Name)
if err != nil {
o.logger.Warn("Failed to initialise RPC client",
zap.String("network", network.Name), zap.String("network", network.Name),
zap.String("rpc_url", rpcURL),
zap.Error(err), zap.Error(err),
) )
return "", err return "", err
@@ -98,17 +101,16 @@ 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)
} }
gasPrice, err := client.SuggestGasPrice(ctx) gasPrice, err := client.SuggestGasPrice(ctx)
if err != nil { if err != nil {
o.logger.Warn("failed to suggest gas price", o.logger.Warn("Failed to suggest gas price",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
@@ -122,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
chainID := new(big.Int).SetUint64(network.ChainID) chainID := new(big.Int).SetUint64(network.ChainID)
if strings.TrimSpace(transfer.ContractAddress) == "" { if strings.TrimSpace(transfer.ContractAddress) == "" {
o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef)) o.logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
return "", merrors.NotImplemented("executor: native token transfers not yet supported") return "", merrors.NotImplemented("executor: native token transfers not yet supported")
} }
if !common.IsHexAddress(transfer.ContractAddress) { if !common.IsHexAddress(transfer.ContractAddress) {
o.logger.Warn("invalid token contract address", o.logger.Warn("Invalid token contract address",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("contract", transfer.ContractAddress), zap.String("contract", transfer.ContractAddress),
) )
@@ -135,34 +137,32 @@ 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
} }
amount := transfer.NetAmount amount := transfer.NetAmount
if amount == nil || strings.TrimSpace(amount.Amount) == "" { if amount == nil || strings.TrimSpace(amount.Amount) == "" {
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef)) o.logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
return "", executorInvalid("transfer missing net amount") return "", executorInvalid("transfer missing net amount")
} }
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
} }
input, err := erc20ABI.Pack("transfer", destination, amountInt) input, err := erc20ABI.Pack("transfer", destination, amountInt)
if err != nil { if err != nil {
o.logger.Warn("failed to encode transfer call", o.logger.Warn("Failed to encode transfer call",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.Error(err), zap.Error(err),
) )
@@ -177,7 +177,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
} }
gasLimit, err := client.EstimateGas(ctx, callMsg) gasLimit, err := client.EstimateGas(ctx, callMsg)
if err != nil { if err != nil {
o.logger.Warn("failed to estimate gas", o.logger.Warn("Failed to estimate gas",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.Error(err), zap.Error(err),
) )
@@ -188,24 +188,22 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID) 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)
} }
txHash = signedTx.Hash().Hex() txHash = signedTx.Hash().Hex()
o.logger.Info("transaction submitted", o.logger.Info("Transaction submitted",
zap.String("transfer_ref", transfer.TransferRef), zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
@@ -214,42 +212,18 @@ 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))
return nil, executorInvalid("tx hash is required") return nil, executorInvalid("tx hash is required")
} }
rpcURL := strings.TrimSpace(network.RPCURL) rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" { if rpcURL == "" {
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash)) o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
return nil, executorInvalid("network rpc url is not configured") 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
} }
@@ -264,27 +238,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
if errors.Is(err, ethereum.NotFound) { if errors.Is(err, ethereum.NotFound) {
select { select {
case <-ticker.C: case <-ticker.C:
o.logger.Debug("transaction not yet mined", o.logger.Debug("Transaction not yet mined",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
continue continue
case <-ctx.Done(): case <-ctx.Done():
o.logger.Warn("context cancelled while awaiting confirmation", o.logger.Warn("Context cancelled while awaiting confirmation",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
) )
return nil, ctx.Err() return nil, ctx.Err()
} }
} }
o.logger.Warn("failed to fetch transaction receipt", o.logger.Warn("Failed to fetch transaction receipt",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Error(err), zap.Error(err),
) )
return nil, executorInternal("failed to fetch transaction receipt", err) return nil, executorInternal("failed to fetch transaction receipt", err)
} }
o.logger.Info("transaction confirmed", o.logger.Info("Transaction confirmed",
zap.String("tx_hash", txHash), zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.String("network", network.Name),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()), zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
} }
]` ]`
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) { 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,204 @@
package rpcclient
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Clients holds pre-initialised RPC clients keyed by network name.
type Clients struct {
logger mlogger.Logger
clients map[string]clientEntry
}
type clientEntry struct {
eth *ethclient.Client
rpc *rpc.Client
}
// Prepare dials all configured networks up front and returns a ready-to-use client set.
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) {
if logger == nil {
return nil, merrors.Internal("rpc clients: logger is required")
}
clientLogger := logger.Named("rpc_client")
result := &Clients{
logger: clientLogger,
clients: make(map[string]clientEntry),
}
for _, network := range networks {
name := strings.ToLower(strings.TrimSpace(network.Name))
rpcURL := strings.TrimSpace(network.RPCURL)
if name == "" {
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
continue
}
if rpcURL == "" {
result.Close()
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
clientLogger.Warn("Rpc url missing", zap.String("network", name))
return nil, err
}
fields := []zap.Field{
zap.String("network", name),
}
clientLogger.Info("Initialising rpc client", fields...)
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
httpClient := &http.Client{
Transport: &loggingRoundTripper{
logger: clientLogger,
network: name,
endpoint: rpcURL,
base: http.DefaultTransport,
},
}
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
cancel()
if err != nil {
result.Close()
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
}
client := ethclient.NewClient(rpcCli)
result.clients[name] = clientEntry{
eth: client,
rpc: rpcCli,
}
clientLogger.Info("RPC client ready", fields...)
}
if len(result.clients) == 0 {
clientLogger.Warn("No rpc clients were initialised")
return nil, merrors.InvalidArgument("no rpc clients initialised")
} else {
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
}
return result, nil
}
// Client returns a prepared client for the given network name.
func (c *Clients) Client(network string) (*ethclient.Client, error) {
if c == nil {
return nil, merrors.Internal("RPC clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.eth == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
}
return entry.eth, nil
}
// RPCClient returns the raw RPC client for low-level calls.
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
if c == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
if !ok || entry.rpc == nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
}
return entry.rpc, nil
}
// Close tears down all RPC clients, logging each close.
func (c *Clients) Close() {
if c == nil {
return
}
for name, entry := range c.clients {
if entry.rpc != nil {
entry.rpc.Close()
} else if entry.eth != nil {
entry.eth.Close()
}
if c.logger != nil {
c.logger.Info("RPC client closed", zap.String("network", name))
}
}
}
type loggingRoundTripper struct {
logger mlogger.Logger
network string
endpoint string
base http.RoundTripper
}
func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if l.base == nil {
l.base = http.DefaultTransport
}
var reqBody []byte
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
reqBody = raw
req.Body = io.NopCloser(strings.NewReader(string(raw)))
}
fields := []zap.Field{
zap.String("network", l.network),
}
if len(reqBody) > 0 {
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
}
l.logger.Debug("RPC request", fields...)
resp, err := l.base.RoundTrip(req)
if err != nil {
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
return nil, err
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
respFields := append(fields,
zap.Int("status_code", resp.StatusCode),
)
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
respFields = append(respFields, zap.String("content_type", contentType))
}
if len(bodyBytes) > 0 {
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
}
l.logger.Debug("RPC response", respFields...)
if resp.StatusCode >= 400 {
l.logger.Warn("RPC response error", respFields...)
} else if len(bodyBytes) == 0 {
l.logger.Warn("RPC response empty body", respFields...)
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
l.logger.Warn("RPC response invalid JSON", respFields...)
}
return resp, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}

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

@@ -3,18 +3,23 @@ package gateway
import ( import (
"context" "context"
"github.com/tech/sendico/gateway/chain/internal/appversion"
"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/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"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging" msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@@ -29,7 +34,7 @@ var (
errStorageUnavailable = serviceError("chain_gateway: storage not initialised") errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
) )
// Service implements the ChainGatewayService RPC contract. // Service implements the ConnectorService RPC contract for chain operations.
type Service struct { type Service struct {
logger mlogger.Logger logger mlogger.Logger
storage storage.Repository storage storage.Repository
@@ -38,13 +43,16 @@ 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
announcers []*discovery.Announcer
chainv1.UnimplementedChainGatewayServiceServer connectorv1.UnimplementedConnectorServiceServer
} }
// NewService constructs the chain gateway service skeleton. // NewService constructs the chain gateway service skeleton.
@@ -73,11 +81,13 @@ 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),
Transfer: commandsTransferDeps(svc), Transfer: commandsTransferDeps(svc),
}) })
svc.startDiscoveryAnnouncers()
return svc return svc
} }
@@ -85,10 +95,21 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
// Register wires the service onto the provided gRPC router. // Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error { func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) { return router.Register(func(reg grpc.ServiceRegistrar) {
chainv1.RegisterChainGatewayServiceServer(reg, s) connectorv1.RegisterConnectorServiceServer(reg, s)
}) })
} }
func (s *Service) Shutdown() {
if s == nil {
return
}
for _, announcer := range s.announcers {
if announcer != nil {
announcer.Stop()
}
}
}
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req) return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
} }
@@ -121,6 +142,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 +160,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 +174,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,
} }
@@ -157,3 +190,30 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
observeRPC(method, err, svc.clock.Now().Sub(start)) observeRPC(method, err, svc.clock.Now().Sub(start))
return resp, err return resp, err
} }
func (s *Service) startDiscoveryAnnouncers() {
if s == nil || s.producer == nil || len(s.networks) == 0 {
return
}
version := appversion.Create().Short()
for _, network := range s.networks {
currencies := []string{shared.NativeCurrency(network)}
for _, token := range network.TokenConfigs {
if token.Symbol != "" {
currencies = append(currencies, token.Symbol)
}
}
announce := discovery.Announcement{
Service: "CRYPTO_RAIL_GATEWAY",
Rail: "CRYPTO",
Network: network.Name,
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"},
Currencies: currencies,
InvokeURI: discovery.DefaultInvokeURI(string(mservice.ChainGateway)),
Version: version,
}
announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce)
announcer.Start()
s.announcers = append(s.announcers, announcer)
}
}

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

@@ -119,13 +119,23 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
} }
} }
// NativeCurrency returns the canonical native token symbol for a network.
func NativeCurrency(network Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(strings.TrimSpace(network.Name))
}
return currency
}
// Network describes a supported blockchain network and known token contracts. // 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)
} }
@@ -41,49 +41,73 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
} }
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination) driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name)
if err != nil { 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
}
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
s.logger.Info("Self transfer detected; skipping submission",
zap.String("transfer_ref", transferRef),
zap.String("wallet_ref", sourceWalletRef),
zap.String("network", network.Name),
)
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
}
return nil
}
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
if err != nil { 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
} }
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
s.logger.Warn("failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) 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))
} }
return err return err
} }
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil {
s.logger.Warn("failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
return nil return nil
} }
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil { if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil {
s.logger.Warn("failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
return nil 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 +116,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

@@ -28,9 +28,10 @@ type ServiceFee struct {
} }
type TransferDestination struct { type TransferDestination struct {
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"` ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"` ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"` ExternalAddressOriginal string `bson:"externalAddressOriginal,omitempty" json:"externalAddressOriginal,omitempty"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
} }
// Transfer models an on-chain transfer orchestrated by the gateway. // Transfer models an on-chain transfer orchestrated by the gateway.
@@ -85,7 +86,8 @@ func (t *Transfer) Normalize() {
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol)) t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress)) t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef) t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
t.Destination.ExternalAddress = strings.TrimSpace(strings.ToLower(t.Destination.ExternalAddress)) t.Destination.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress)
t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal)
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo) t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
t.ClientReference = strings.TrimSpace(t.ClientReference) t.ClientReference = strings.TrimSpace(t.ClientReference)
} }

View File

@@ -0,0 +1,50 @@
package model
import (
"strings"
"testing"
)
func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) {
address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
transfer := &Transfer{
IdempotencyKey: "idemp",
TransferRef: "ref",
OrganizationRef: "org",
SourceWalletRef: "wallet",
Network: "tron_mainnet",
TokenSymbol: "USDT",
Destination: TransferDestination{
ExternalAddress: address,
ExternalAddressOriginal: address,
},
}
transfer.Normalize()
if transfer.Destination.ExternalAddress != address {
t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress)
}
if transfer.Destination.ExternalAddressOriginal != address {
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
}
}
func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) {
address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD"
transfer := &Transfer{
Destination: TransferDestination{
ExternalAddress: address,
ExternalAddressOriginal: address,
},
}
transfer.Normalize()
if transfer.Destination.ExternalAddress != strings.ToLower(address) {
t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress)
}
if transfer.Destination.ExternalAddressOriginal != address {
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
}
}

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