87 Commits

Author SHA1 Message Date
Arseni
ad3d44f137 Fixes for build 2025-12-26 19:26:19 +03:00
Arseni
f339630115 Moved all the payment data preparation logic from the paymentFlowProvider to the payment and walletproviders 2025-12-26 15:11:47 +03:00
Arseni
75d5a512cd Removed manual syncWith/reset calls and added an update in PaymentFlowProvider 2025-12-26 14:37:45 +03:00
Arseni
1811571f80 Fixed search field in payment page and cleaned up paymentFlow 2025-12-26 13:29:51 +03:00
Arseni
edfdef5211 Fixed search field in payment page and cleaned up payment flow 2025-12-26 11:19:49 +03: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
216 changed files with 7705 additions and 2234 deletions

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
) )
@@ -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-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
) )

View File

@@ -212,10 +212,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

@@ -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-20251213004720-97cd9d5aeac2 // 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.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -212,10 +212,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

@@ -13,7 +13,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
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -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-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -212,10 +212,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

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

View File

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

View File

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

View File

@@ -15,14 +15,14 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11 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-20251213223233-751f36331c62 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
@@ -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-20251213004720-97cd9d5aeac2 // 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-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -362,10 +362,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

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

View File

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

View File

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

View File

@@ -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,25 @@ 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()), 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,11 +24,11 @@ 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"))
} }
@@ -55,194 +46,72 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
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

@@ -25,7 +25,7 @@ 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 {
@@ -35,84 +35,92 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
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("mMssing 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,34 +42,33 @@ type onChainExecutor struct {
logger mlogger.Logger logger mlogger.Logger
keyManager keymanager.Manager keyManager keymanager.Manager
mu sync.Mutex clients *rpcclient.Clients
clients map[string]*ethclient.Client
} }
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) { func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
if o.keyManager == nil { if o.keyManager == nil {
o.logger.Error("key manager not configured") o.logger.Warn("key manager not configured")
return "", executorInternal("key manager is not configured", nil) return "", executorInternal("key manager is not configured", nil)
} }
rpcURL := strings.TrimSpace(network.RPCURL) rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" { if rpcURL == "" {
o.logger.Error("network rpc url missing", zap.String("network", network.Name)) o.logger.Warn("network rpc url missing", zap.String("network", network.Name))
return "", executorInvalid("network rpc url is not configured") return "", executorInvalid("network rpc url is not configured")
} }
if source == nil || transfer == nil { if source == nil || transfer == nil {
o.logger.Error("transfer context missing") o.logger.Warn("transfer context missing")
return "", executorInvalid("transfer context missing") return "", executorInvalid("transfer context missing")
} }
if strings.TrimSpace(source.KeyReference) == "" { if strings.TrimSpace(source.KeyReference) == "" {
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef)) o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference") return "", executorInvalid("source wallet missing key reference")
} }
if strings.TrimSpace(source.DepositAddress) == "" { if strings.TrimSpace(source.DepositAddress) == "" {
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef)) o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing deposit address") return "", executorInvalid("source wallet missing deposit address")
} }
if !common.IsHexAddress(destinationAddress) { if !common.IsHexAddress(destinationAddress) {
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress)) o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
return "", executorInvalid("invalid destination address " + destinationAddress) return "", executorInvalid("invalid destination address " + destinationAddress)
} }
@@ -80,11 +79,15 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
zap.String("destination", strings.ToLower(destinationAddress)), zap.String("destination", strings.ToLower(destinationAddress)),
) )
client, err := o.getClient(ctx, rpcURL) client, err := o.clients.Client(network.Name)
if err != nil { 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,27 +137,25 @@ 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
} }
@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -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)
} }
@@ -44,13 +44,37 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
} }
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination) driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name)
if err != nil { if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err return err
} }
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network) destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
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
@@ -62,7 +86,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel() defer cancel()
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash) receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
if err != nil { if err != nil {
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err)) s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
@@ -83,7 +107,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
return nil return nil
} }
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) { func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" { if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
wallet, err := s.storage.Wallets().Get(ctx, ref) wallet, err := s.storage.Wallets().Get(ctx, ref)
if err != nil { if err != nil {
@@ -92,10 +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

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

View File

@@ -99,24 +99,49 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
if strings.TrimSpace(wallet.IdempotencyKey) == "" { if strings.TrimSpace(wallet.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey") return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
} }
fields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("idempotency_key", wallet.IdempotencyKey),
}
if wallet.OrganizationRef != "" {
fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef))
}
if wallet.OwnerRef != "" {
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
}
if wallet.Network != "" {
fields = append(fields, zap.String("network", wallet.Network))
}
if wallet.TokenSymbol != "" {
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
}
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil { if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey)) w.logger.Debug("wallet already exists", fields...)
return wallet, nil return wallet, nil
} }
w.logger.Warn("wallet create failed", append(fields, zap.Error(err))...)
return nil, err return nil, err
} }
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef)) w.logger.Debug("wallet created", fields...)
return wallet, nil return wallet, nil
} }
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) { func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
walletRef = strings.TrimSpace(walletRef) walletID = strings.TrimSpace(walletID)
if walletRef == "" { if walletID == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef") return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
} }
fields := []zap.Field{
zap.String("wallet_id", walletID),
}
wallet := &model.ManagedWallet{} wallet := &model.ManagedWallet{}
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil { if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
if errors.Is(err, merrors.ErrNoData) {
w.logger.Debug("wallet not found", fields...)
} else {
w.logger.Warn("wallet lookup failed", append(fields, zap.Error(err))...)
}
return nil, err return nil, err
} }
return wallet, nil return wallet, nil
@@ -124,29 +149,38 @@ func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWall
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) { func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
query := repository.Query() query := repository.Query()
fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(filter.OrganizationRef); org != "" { if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org) query = query.Filter(repository.Field("organizationRef"), org)
fields = append(fields, zap.String("organization_ref", org))
} }
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" { if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
query = query.Filter(repository.Field("ownerRef"), owner) query = query.Filter(repository.Field("ownerRef"), owner)
fields = append(fields, zap.String("owner_ref", owner))
} }
if network := strings.TrimSpace(filter.Network); network != "" { if network := strings.TrimSpace(filter.Network); network != "" {
query = query.Filter(repository.Field("network"), strings.ToLower(network)) normalized := strings.ToLower(network)
query = query.Filter(repository.Field("network"), normalized)
fields = append(fields, zap.String("network", normalized))
} }
if token := strings.TrimSpace(filter.TokenSymbol); token != "" { if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token)) normalized := strings.ToUpper(token)
query = query.Filter(repository.Field("tokenSymbol"), normalized)
fields = append(fields, zap.String("token_symbol", normalized))
} }
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid) query = query.Comparison(repository.IDField(), builder.Gt, oid)
fields = append(fields, zap.String("cursor", cursor))
} else { } else {
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err)) w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
} }
} }
limit := sanitizeWalletLimit(filter.Limit) limit := sanitizeWalletLimit(filter.Limit)
fields = append(fields, zap.Int64("limit", limit))
fetchLimit := limit + 1 fetchLimit := limit + 1
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit) query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
@@ -160,8 +194,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
return nil return nil
} }
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
return nil, err if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
w.logger.Warn("wallet list failed", append(fields, zap.Error(listErr))...)
return nil, listErr
} }
nextCursor := "" nextCursor := ""
@@ -171,10 +207,21 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
wallets = wallets[:len(wallets)-1] wallets = wallets[:len(wallets)-1]
} }
return &model.ManagedWalletList{ result := &model.ManagedWalletList{
Items: wallets, Items: wallets,
NextCursor: nextCursor, NextCursor: nextCursor,
}, nil }
fields = append(fields,
zap.Int("count", len(result.Items)),
zap.String("next_cursor", result.NextCursor),
)
if errors.Is(listErr, merrors.ErrNoData) {
w.logger.Debug("wallet list empty", fields...)
} else {
w.logger.Debug("wallet list fetched", fields...)
}
return result, nil
} }
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error { func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
@@ -188,6 +235,7 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
if balance.CalculatedAt.IsZero() { if balance.CalculatedAt.IsZero() {
balance.CalculatedAt = time.Now().UTC() balance.CalculatedAt = time.Now().UTC()
} }
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
existing := &model.WalletBalance{} existing := &model.WalletBalance{}
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing) err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
@@ -198,28 +246,40 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
existing.PendingOutbound = balance.PendingOutbound existing.PendingOutbound = balance.PendingOutbound
existing.CalculatedAt = balance.CalculatedAt existing.CalculatedAt = balance.CalculatedAt
if err := w.balanceRepo.Update(ctx, existing); err != nil { if err := w.balanceRepo.Update(ctx, existing); err != nil {
w.logger.Warn("wallet balance update failed", append(fields, zap.Error(err))...)
return err return err
} }
w.logger.Debug("wallet balance updated", fields...)
return nil return nil
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil { if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
w.logger.Warn("wallet balance create failed", append(fields, zap.Error(err))...)
return err return err
} }
w.logger.Debug("wallet balance created", fields...)
return nil return nil
default: default:
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
return err return err
} }
} }
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) { func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
walletRef = strings.TrimSpace(walletRef) walletID = strings.TrimSpace(walletID)
if walletRef == "" { if walletID == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef") return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
} }
fields := []zap.Field{zap.String("wallet_ref", walletID)}
balance := &model.WalletBalance{} balance := &model.WalletBalance{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil { if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
if errors.Is(err, merrors.ErrNoData) {
w.logger.Debug("wallet balance not found", fields...)
} else {
w.logger.Warn("wallet balance lookup failed", append(fields, zap.Error(err))...)
}
return nil, err return nil, err
} }
w.logger.Debug("wallet balance fetched", fields...)
return balance, nil return balance, nil
} }

View File

@@ -26,7 +26,7 @@ monetix:
base_url_env: MONETIX_BASE_URL base_url_env: MONETIX_BASE_URL
project_id_env: MONETIX_PROJECT_ID project_id_env: MONETIX_PROJECT_ID
secret_key_env: MONETIX_SECRET_KEY secret_key_env: MONETIX_SECRET_KEY
allowed_currencies: ["USD", "EUR"] allowed_currencies: ["RUB"]
require_customer_address: false require_customer_address: false
request_timeout_seconds: 15 request_timeout_seconds: 15
status_success: "success" status_success: "success"

View File

@@ -11,7 +11,7 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -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-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -214,10 +214,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

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
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -51,5 +51,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -214,10 +214,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

@@ -52,7 +52,7 @@ require (
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // 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.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -227,10 +227,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

@@ -51,6 +51,12 @@ gateway:
call_timeout_seconds: 3 call_timeout_seconds: 3
insecure: true insecure: true
mntx:
address: "sendico_mntx_gateway:50075"
dial_timeout_seconds: 5
call_timeout_seconds: 3
insecure: true
oracle: oracle:
address: "sendico_fx_oracle:50051" address: "sendico_fx_oracle:50051"
dial_timeout_seconds: 5 dial_timeout_seconds: 5
@@ -59,8 +65,8 @@ oracle:
card_gateways: card_gateways:
monetix: monetix:
funding_address: "wallet_funding_monetix" funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
fee_address: "wallet_fee_monetix" fee_wallet_ref: "694c124ed76f9f811ac57133"
fee_ledger_accounts: fee_ledger_accounts:
monetix: "ledger:fees:monetix" monetix: "ledger:fees:monetix"

View File

@@ -24,7 +24,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
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -62,5 +62,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -215,10 +215,10 @@ 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-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

@@ -9,6 +9,7 @@ import (
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client" chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
@@ -36,6 +37,7 @@ type Imp struct {
feesConn *grpc.ClientConn feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client ledgerClient ledgerclient.Client
gatewayClient chainclient.Client gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client oracleClient oracleclient.Client
} }
@@ -44,6 +46,7 @@ type config struct {
Fees clientConfig `yaml:"fees"` Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"` Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"` Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"` Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"` FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
@@ -59,6 +62,7 @@ type clientConfig struct {
type cardGatewayRouteConfig struct { type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"` FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"` FeeAddress string `yaml:"fee_address"`
FeeWalletRef string `yaml:"fee_wallet_ref"`
} }
func (c clientConfig) address() string { func (c clientConfig) address() string {
@@ -104,6 +108,9 @@ func (i *Imp) Shutdown() {
if i.gatewayClient != nil { if i.gatewayClient != nil {
_ = i.gatewayClient.Close() _ = i.gatewayClient.Close()
} }
if i.mntxClient != nil {
_ = i.mntxClient.Close()
}
if i.oracleClient != nil { if i.oracleClient != nil {
_ = i.oracleClient.Close() _ = i.oracleClient.Close()
} }
@@ -138,6 +145,11 @@ func (i *Imp) Start() error {
i.gatewayClient = gatewayClient i.gatewayClient = gatewayClient
} }
mntxClient := i.initMntxClient(cfg.Mntx)
if mntxClient != nil {
i.mntxClient = mntxClient
}
oracleClient := i.initOracleClient(cfg.Oracle) oracleClient := i.initOracleClient(cfg.Oracle)
if oracleClient != nil { if oracleClient != nil {
i.oracleClient = oracleClient i.oracleClient = oracleClient
@@ -154,6 +166,9 @@ func (i *Imp) Start() error {
if gatewayClient != nil { if gatewayClient != nil {
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient)) opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
} }
if mntxClient != nil {
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
}
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient)) opts = append(opts, orchestrator.WithOracleClient(oracleClient))
} }
@@ -191,11 +206,11 @@ func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.Cl
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds)) conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
if err != nil { if err != nil {
i.logger.Warn("failed to connect to fees service", zap.String("address", addr), zap.Error(err)) i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
return nil, nil return nil, nil
} }
i.logger.Info("connected to fees service", zap.String("address", addr)) i.logger.Info("Connected to fees service", zap.String("address", addr))
return feesv1.NewFeeEngineClient(conn), conn return feesv1.NewFeeEngineClient(conn), conn
} }
@@ -215,10 +230,10 @@ func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
Insecure: cfg.InsecureTransport, Insecure: cfg.InsecureTransport,
}) })
if err != nil { if err != nil {
i.logger.Warn("failed to connect to ledger service", zap.String("address", addr), zap.Error(err)) i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
return nil return nil
} }
i.logger.Info("connected to ledger service", zap.String("address", addr)) i.logger.Info("Connected to ledger service", zap.String("address", addr))
return client return client
} }
@@ -245,6 +260,28 @@ func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
return client return client
} }
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
addr := cfg.address()
if addr == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
defer cancel()
client, err := mntxclient.New(ctx, mntxclient.Config{
Address: addr,
DialTimeout: cfg.dialTimeout(),
CallTimeout: cfg.callTimeout(),
})
if err != nil {
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
return nil
}
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
return client
}
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client { func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
addr := cfg.address() addr := cfg.address()
if addr == "" { if addr == "" {
@@ -261,10 +298,10 @@ func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
Insecure: cfg.InsecureTransport, Insecure: cfg.InsecureTransport,
}) })
if err != nil { if err != nil {
i.logger.Warn("failed to connect to oracle service", zap.String("address", addr), zap.Error(err)) i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
return nil return nil
} }
i.logger.Info("connected to oracle service", zap.String("address", addr)) i.logger.Info("Connected to oracle service", zap.String("address", addr))
return client return client
} }
@@ -323,6 +360,7 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
result[trimmedKey] = orchestrator.CardGatewayRoute{ result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress), FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress), FeeAddress: strings.TrimSpace(route.FeeAddress),
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
} }
} }
return result return result

View File

@@ -7,13 +7,21 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap" "go.uber.org/zap"
) )
const defaultCardGateway = "monetix" const (
defaultCardGateway = "monetix"
stepCodeGasTopUp = "gas_top_up"
stepCodeFundingTransfer = "funding_transfer"
stepCodeCardPayout = "card_payout"
stepCodeFeeTransfer = "fee_transfer"
)
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 { if len(s.deps.cardRoutes) == 0 {
@@ -54,24 +62,214 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
if err != nil { if err != nil {
return err return err
} }
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
fundingAddress := strings.TrimSpace(route.FundingAddress)
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
amount := cloneMoney(intent.Amount) amount := cloneMoney(intent.Amount)
if amount == nil { if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: amount is required") return merrors.InvalidArgument("card funding: amount is required")
} }
payoutAmount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
feeMoney := (*moneyv1.Money)(nil)
if quote != nil {
feeMoney = quote.GetExpectedFeeTotal()
}
if feeMoney == nil && payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
feeDecimal := decimal.Zero
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: fee currency is required")
}
feeDecimal, err = decimalFromMoney(feeMoney)
if err != nil {
return err
}
}
feeRequired := feeDecimal.IsPositive()
fundingDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}
fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount)
if err != nil {
return err
}
var feeTransferFee *moneyv1.Money
if feeRequired {
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
}
feeDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
}
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney)
if err != nil {
return err
}
}
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
if err != nil {
return err
}
var estimatedTotalFee *moneyv1.Money
if gasCurrency != "" && !totalFee.IsNegative() {
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
}
var topUpMoney *moneyv1.Money
var topUpFee *moneyv1.Money
topUpPositive := false
if estimatedTotalFee != nil {
computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
})
if err != nil {
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if computeResp != nil {
topUpMoney = computeResp.GetTopupAmount()
}
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
amountDec, err := decimalFromMoney(topUpMoney)
if err != nil {
return err
}
topUpPositive = amountDec.IsPositive()
}
if topUpMoney != nil && topUpPositive {
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
if err != nil {
return err
}
}
}
plan := ensureExecutionPlan(payment)
var gasStep *model.ExecutionStep
if topUpMoney != nil && topUpPositive {
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
gasStep.Description = "Top up native gas from fee wallet"
gasStep.Amount = cloneMoney(topUpMoney)
gasStep.NetworkFee = cloneMoney(topUpFee)
gasStep.SourceWalletRef = feeWalletRef
gasStep.DestinationRef = sourceWalletRef
}
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
fundStep.Description = "Transfer payout amount to card funding wallet"
fundStep.Amount = cloneMoney(amount)
fundStep.NetworkFee = cloneMoney(fundingFee)
fundStep.SourceWalletRef = sourceWalletRef
fundStep.DestinationRef = fundingAddress
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
cardStep.Description = "Submit card payout"
cardStep.Amount = cloneMoney(payoutAmount)
if card := intent.Destination.Card; card != nil {
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
cardStep.DestinationRef = masked
}
}
if feeRequired {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.NetworkFee = cloneMoney(feeTransferFee)
step.SourceWalletRef = sourceWalletRef
step.DestinationRef = feeWalletRef
}
updateExecutionPlanTotalNetworkFee(plan)
exec := payment.Execution exec := payment.Execution
if exec == nil { if exec == nil {
exec = &model.ExecutionRefs{} exec = &model.ExecutionRefs{}
} }
if topUpMoney != nil && topUpPositive {
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: feeWalletRef,
TargetWalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
})
if gasErr != nil {
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
return gasErr
}
if gasStep != nil {
actual := (*moneyv1.Money)(nil)
if ensureResp != nil {
actual = ensureResp.GetTopupAmount()
if transfer := ensureResp.GetTransfer(); transfer != nil {
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
}
}
actualPositive := false
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
actualDec, err := decimalFromMoney(actual)
if err != nil {
return err
}
actualPositive = actualDec.IsPositive()
}
if actual != nil && actualPositive {
gasStep.Amount = cloneMoney(actual)
if strings.TrimSpace(actual.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = cloneMoney(topUpFee)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
}
}
if gasStep != nil {
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
}
updateExecutionPlanTotalNetworkFee(plan)
}
// Transfer payout amount to funding wallet. // Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{ fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund", IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(), OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef), SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{ Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)}, Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}, },
Amount: amount, Amount: amount,
Metadata: cloneMetadata(payment.Metadata), Metadata: cloneMetadata(payment.Metadata),
@@ -84,42 +282,10 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
} }
if fundResp != nil && fundResp.GetTransfer() != nil { if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
fundStep.TransferRef = exec.ChainTransferRef
} }
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef)) s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
feeMoney := quote.GetExpectedFeeTotal()
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(route.FeeAddress) == "" {
return merrors.InvalidArgument("card funding: fee address is required when fee exists")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
}
}
payment.Execution = exec payment.Execution = exec
return nil return nil
} }
@@ -133,9 +299,9 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
if card == nil { if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required") return merrors.InvalidArgument("card payout: card endpoint is required")
} }
amount := cloneMoney(intent.Amount) amount, err := cardPayoutAmount(payment)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { if err != nil {
return merrors.InvalidArgument("card payout: amount is required") return err
} }
amtDec, err := decimalFromMoney(amount) amtDec, err := decimalFromMoney(amount)
if err != nil { if err != nil {
@@ -193,13 +359,92 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
return merrors.Internal("card payout: missing payout state") return merrors.Internal("card payout: missing payout state")
} }
recordCardPayoutState(payment, state) recordCardPayoutState(payment, state)
if payment.Execution == nil { exec := payment.Execution
payment.Execution = &model.ExecutionRefs{} if exec == nil {
exec = &model.ExecutionRefs{}
} }
if payment.Execution.CardPayoutRef == "" { if exec.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
} }
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef)) payment.Execution = exec
plan := ensureExecutionPlan(payment)
if plan != nil {
step := ensureExecutionStep(plan, stepCodeCardPayout)
step.Description = "Submit card payout"
step.Amount = cloneMoney(amount)
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
step.DestinationRef = masked
}
if exec.CardPayoutRef != "" {
step.TransferRef = exec.CardPayoutRef
}
updateExecutionPlanTotalNetworkFee(plan)
}
feeMoney := (*moneyv1.Money)(nil)
if payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: fee currency is required")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
if !s.deps.gateway.available() {
s.logger.Warn("card fee aborted: chain gateway unavailable")
return merrors.InvalidArgument("card payout: chain gateway unavailable")
}
sourceWallet := intent.Source.ManagedWallet
if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card payout: source managed wallet is required")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
if feeWalletRef == "" {
return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists")
}
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
if plan != nil {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef)
step.DestinationRef = feeWalletRef
step.TransferRef = exec.FeeTransferRef
updateExecutionPlanTotalNetworkFee(plan)
}
}
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef))
return nil return nil
} }
@@ -250,3 +495,147 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat
// leave as-is for pending/unspecified // leave as-is for pending/unspecified
} }
} }
func cardPayoutAmount(payment *model.Payment) (*moneyv1.Money, error) {
if payment == nil {
return nil, merrors.InvalidArgument("payment is required")
}
amount := cloneMoney(payment.Intent.Amount)
if payment.LastQuote != nil {
settlement := payment.LastQuote.ExpectedSettlementAmount
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
amount = cloneMoney(settlement)
}
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("card payout: amount is required")
}
return amount, nil
}
func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
if !s.deps.gateway.available() {
return nil, merrors.InvalidArgument("chain gateway unavailable")
}
sourceWalletRef = strings.TrimSpace(sourceWalletRef)
if sourceWalletRef == "" {
return nil, merrors.InvalidArgument("source wallet ref is required")
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("amount is required")
}
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: sourceWalletRef,
Destination: destination,
Amount: cloneMoney(amount),
})
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
if resp == nil {
s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
fee := resp.GetNetworkFee()
if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return cloneMoney(fee), nil
}
func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) {
total := decimal.Zero
currency := ""
for _, fee := range fees {
if fee == nil {
continue
}
amount := strings.TrimSpace(fee.GetAmount())
feeCurrency := strings.TrimSpace(fee.GetCurrency())
if amount == "" || feeCurrency == "" {
return decimal.Zero, "", merrors.InvalidArgument("network fee is required")
}
value, err := decimalFromMoney(fee)
if err != nil {
return decimal.Zero, "", err
}
if currency == "" {
currency = feeCurrency
} else if !strings.EqualFold(currency, feeCurrency) {
return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch")
}
total = total.Add(value)
}
return total, currency, nil
}
func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan {
if payment == nil {
return nil
}
if payment.ExecutionPlan == nil {
payment.ExecutionPlan = &model.ExecutionPlan{}
}
return payment.ExecutionPlan
}
func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep {
if plan == nil {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
return nil
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if strings.EqualFold(step.Code, code) {
if step.Code == "" {
step.Code = code
}
return step
}
}
step := &model.ExecutionStep{Code: code}
plan.Steps = append(plan.Steps, step)
return step
}
func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) {
if plan == nil {
return
}
total := decimal.Zero
currency := ""
hasFee := false
for _, step := range plan.Steps {
if step == nil || step.NetworkFee == nil {
continue
}
fee := step.NetworkFee
if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
continue
}
if currency == "" {
currency = strings.TrimSpace(fee.GetCurrency())
} else if !strings.EqualFold(currency, fee.GetCurrency()) {
continue
}
value, err := decimalFromMoney(fee)
if err != nil {
continue
}
total = total.Add(value)
hasFee = true
}
if !hasFee || currency == "" {
plan.TotalNetworkFee = nil
return
}
plan.TotalNetworkFee = makeMoney(currency, total)
}

View File

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

View File

@@ -13,7 +13,7 @@ import (
type paymentEngine interface { type paymentEngine interface {
EnsureRepository(ctx context.Context) error EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository Repository() storage.Repository
} }
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
return e.svc.buildPaymentQuote(ctx, orgRef, req) return e.svc.buildPaymentQuote(ctx, orgRef, req)
} }
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) { func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
return e.svc.resolvePaymentQuote(ctx, in) return e.svc.resolvePaymentQuote(ctx, in)
} }

View File

@@ -5,9 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
@@ -127,6 +125,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
FailureReason: src.FailureReason, FailureReason: src.FailureReason,
LastQuote: modelQuoteToProto(src.LastQuote), LastQuote: modelQuoteToProto(src.LastQuote),
Execution: protoExecutionFromModel(src.Execution), Execution: protoExecutionFromModel(src.Execution),
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
Metadata: cloneMetadata(src.Metadata), Metadata: cloneMetadata(src.Metadata),
} }
if src.CardPayout != nil { if src.CardPayout != nil {
@@ -253,6 +252,41 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
} }
} }
func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep {
if src == nil {
return nil
}
return &orchestratorv1.ExecutionStep{
Code: src.Code,
Description: src.Description,
Amount: cloneMoney(src.Amount),
NetworkFee: cloneMoney(src.NetworkFee),
SourceWalletRef: src.SourceWalletRef,
DestinationRef: src.DestinationRef,
TransferRef: src.TransferRef,
Metadata: cloneMetadata(src.Metadata),
}
}
func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan {
if src == nil {
return nil
}
steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps))
for _, step := range src.Steps {
if protoStep := protoExecutionStepFromModel(step); protoStep != nil {
steps = append(steps, protoStep)
}
}
if len(steps) == 0 {
steps = nil
}
return &orchestratorv1.ExecutionPlan{
Steps: steps,
TotalNetworkFee: cloneMoney(src.TotalNetworkFee),
}
}
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote { func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
if src == nil { if src == nil {
return nil return nil
@@ -413,74 +447,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
} }
return nil return nil
} }
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
switch code {
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
return model.PaymentFailureCodeBalance
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
return model.PaymentFailureCodeLedger
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
return model.PaymentFailureCodeFX
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
return model.PaymentFailureCodeChain
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
return model.PaymentFailureCodeFees
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
return model.PaymentFailureCodePolicy
default:
return model.PaymentFailureCodeUnspecified
}
}
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
if src == nil || dst == nil {
return merrors.InvalidArgument("payment payload is required")
}
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
dst.Intent = intentFromProto(src.GetIntent())
dst.State = modelStateFromProto(src.GetState())
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
if src.GetCardPayout() != nil {
dst.CardPayout = &model.CardPayout{
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
}
}
return nil
}
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
if src == nil {
return nil
}
return &model.ExecutionRefs{
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
}
}
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
if req == nil {
return &paginationv1.CursorPageRequest{}
}
if req.GetPage() == nil {
return &paginationv1.CursorPageRequest{}
}
return req.GetPage()
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap" "go.uber.org/zap"
@@ -61,7 +62,13 @@ func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.Q
if err := quotesStore.Create(ctx, record); err != nil { if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex())) h.logger.Info(
"Stored payment quote",
zap.String("quote_ref", quoteRef),
mzap.ObjRef("org_ref", orgID),
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
zap.String("kind", intent.GetKind().String()),
)
} }
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote}) return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
@@ -79,7 +86,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
if req == nil { if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
} }
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) orgID, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil { if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
@@ -101,7 +108,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
Intent: intent, Intent: intent,
PreviewOnly: req.GetPreviewOnly(), PreviewOnly: req.GetPreviewOnly(),
} }
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq) quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq)
if err != nil { if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
@@ -132,11 +139,14 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
} }
record.SetID(primitive.NewObjectID()) record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID) record.SetOrganizationRef(orgRef)
if err := quotesStore.Create(ctx, record); err != nil { if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex())) h.logger.Info("Stored payment quotes",
zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef),
zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)),
)
} }
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
@@ -158,7 +168,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
if req == nil { if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
} }
_, orgID, err := validateMetaAndOrgRef(req.GetMeta()) _, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil { if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
@@ -175,7 +185,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
if err != nil { if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef) record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) { if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
@@ -213,14 +223,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
quoteProto.QuoteRef = quoteRef quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents)) perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil { if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing)) payments = append(payments, toProtoPayment(existing))
continue continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto) entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil { if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) { if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
@@ -235,6 +245,13 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
payments = append(payments, toProtoPayment(entity)) payments = append(payments, toProtoPayment(entity))
} }
h.logger.Info(
"Payments initiated",
mzap.ObjRef("org_ref", orgRef),
zap.String("quote_ref", quoteRef),
zap.String("idempotency_key", idempotencyKey),
zap.Int("payment_count", len(payments)),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments}) return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
} }
@@ -255,13 +272,31 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
intent := req.GetIntent() intent := req.GetIntent()
if err := requireNonNilIntent(intent); err != nil { quoteRef := strings.TrimSpace(req.GetQuoteRef())
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) hasIntent := intent != nil
hasQuote := quoteRef != ""
switch {
case !hasIntent && !hasQuote:
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
case hasIntent && hasQuote:
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
}
if hasIntent {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
} }
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil { if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
h.logger.Debug(
"Initiate payment request accepted",
zap.String("org_ref", orgID.Hex()),
zap.String("idempotency_key", idempotencyKey),
zap.String("quote_ref", quoteRef),
zap.Bool("has_intent", hasIntent),
)
store, err := ensurePaymentsStore(h.engine.Repository()) store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil { if err != nil {
@@ -269,18 +304,24 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
} }
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex())) h.logger.Debug(
"idempotent payment request reused",
zap.String("payment_ref", existing.PaymentRef),
zap.String("org_ref", orgID.Hex()),
zap.String("idempotency_key", idempotencyKey),
zap.String("quote_ref", quoteRef),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)}) return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef, OrgRef: orgRef,
OrgID: orgID, OrgID: orgID,
Meta: req.GetMeta(), Meta: req.GetMeta(),
Intent: intent, Intent: intent,
QuoteRef: req.GetQuoteRef(), QuoteRef: quoteRef,
IdempotencyKey: req.GetIdempotencyKey(), IdempotencyKey: req.GetIdempotencyKey(),
}) })
if err != nil { if err != nil {
@@ -301,8 +342,17 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
if quoteSnapshot == nil { if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{} quoteSnapshot = &orchestratorv1.PaymentQuote{}
} }
if err := requireNonNilIntent(resolvedIntent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Debug(
"Payment quote resolved",
zap.String("org_ref", orgID.Hex()),
zap.String("quote_ref", quoteRef),
zap.Bool("quote_ref_used", quoteRef != ""),
)
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot) entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil { if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) { if errors.Is(err, storage.ErrDuplicatePayment) {
@@ -315,7 +365,14 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String())) h.logger.Info(
"Payment initiated",
zap.String("payment_ref", entity.PaymentRef),
zap.String("org_ref", orgID.Hex()),
zap.String("kind", resolvedIntent.GetKind().String()),
zap.String("quote_ref", quoteSnapshot.GetQuoteRef()),
zap.String("idempotency_key", idempotencyKey),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity), Payment: toProtoPayment(entity),
}) })
@@ -355,7 +412,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
if err := store.Update(ctx, payment); err != nil { if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex())) h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)}) return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
} }
@@ -396,7 +453,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
} }
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex())) h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)}) return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
@@ -439,7 +496,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
} }
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex())) h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{ return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity), Conversion: toProtoPayment(entity),
}) })

View File

@@ -214,20 +214,6 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
return decimal.NewFromString(m.GetAmount()) return decimal.NewFromString(m.GetAmount())
} }
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
if reference == nil || candidate == nil {
return nil, nil
}
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
return nil, nil
}
value, err := decimal.NewFromString(candidate.GetAmount())
if err != nil {
return nil, err
}
return &value, nil
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money { func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{ return &moneyv1.Money{
Currency: currency, Currency: currency,

View File

@@ -56,10 +56,11 @@ func (m mntxDependency) available() bool {
return m.client != nil return m.client != nil
} }
// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses). // CardGatewayRoute maps a gateway to its funding and fee destinations.
type CardGatewayRoute struct { type CardGatewayRoute struct {
FundingAddress string FundingAddress string
FeeAddress string FeeAddress string
FeeWalletRef string
} }
// WithFeeEngine wires the fee engine client. // WithFeeEngine wires the fee engine client.

View File

@@ -57,7 +57,7 @@ func TestMinQuoteExpiry(t *testing.T) {
later := now.Add(10 * time.Minute) later := now.Add(10 * time.Minute)
earliest := now.Add(5 * time.Minute) earliest := now.Add(5 * time.Minute)
min, ok := minQuoteExpiry([]time.Time{later, time.Time{}, earliest}) min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
if !ok { if !ok {
t.Fatal("expected min expiry to be set") t.Fatal("expected min expiry to be set")
} }
@@ -65,7 +65,7 @@ func TestMinQuoteExpiry(t *testing.T) {
t.Fatalf("expected min expiry %v, got %v", earliest, min) t.Fatalf("expected min expiry %v, got %v", earliest, min)
} }
if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok { if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
t.Fatal("expected min expiry to be unset") t.Fatal("expected min expiry to be unset")
} }
} }

View File

@@ -103,33 +103,40 @@ type quoteResolutionError struct {
func (e quoteResolutionError) Error() string { return e.err.Error() } func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) { func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" { if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage) quotesStore, err := ensureQuotesStore(s.storage)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) { if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
} }
return nil, err return nil, nil, err
} }
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
} }
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) { intent, err := recordIntentFromQuote(record)
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} if err != nil {
return nil, nil, err
} }
quote := modelQuoteToProto(record.Quote) if in.Intent != nil && !proto.Equal(intent, in.Intent) {
if quote == nil { return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
return nil, merrors.InvalidArgument("stored quote is empty") }
quote, err := recordQuoteFromQuote(record)
if err != nil {
return nil, nil, err
} }
quote.QuoteRef = ref quote.QuoteRef = ref
return quote, nil return quote, intent, nil
} }
if in.Intent == nil {
return nil, nil, merrors.InvalidArgument("intent is required")
}
req := &orchestratorv1.QuotePaymentRequest{ req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta, Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey, IdempotencyKey: in.IdempotencyKey,
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
} }
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
return quote, nil return quote, in.Intent, nil
}
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
if record == nil {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
if len(record.Intents) > 0 {
if len(record.Intents) != 1 {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
return protoIntentFromModel(record.Intents[0]), nil
}
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
return protoIntentFromModel(record.Intent), nil
}
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) {
if record == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
if record.Quote != nil {
return modelQuoteToProto(record.Quote), nil
}
if len(record.Quotes) > 0 {
if len(record.Quotes) != 1 {
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
}
return modelQuoteToProto(record.Quotes[0]), nil
}
return nil, merrors.InvalidArgument("stored quote is empty")
} }
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment { func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {

View File

@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
storage: stubRepo{quotes: &helperQuotesStore{}}, storage: stubRepo{quotes: &helperQuotesStore{}},
clock: clockpkg.NewSystem(), clock: clockpkg.NewSystem(),
} }
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(), OrgRef: org.Hex(),
OrgID: org, OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(), clock: clockpkg.NewSystem(),
} }
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(), OrgRef: org.Hex(),
OrgID: org, OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
@@ -110,6 +110,35 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
} }
} }
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
QuoteRef: "q1",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if quote == nil || quote.GetQuoteRef() != "q1" {
t.Fatalf("expected quote_ref q1, got %#v", quote)
}
if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" {
t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent)
}
}
func TestInitiatePaymentIdempotency(t *testing.T) { func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false) logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID() org := primitive.NewObjectID()
@@ -140,6 +169,42 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
} }
} }
func TestInitiatePaymentByQuoteRef(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
}
svc := NewService(logger, stubRepo{
payments: store,
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
}, WithClock(clockpkg.NewSystem()))
svc.ensureHandlers()
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
QuoteRef: "q1",
IdempotencyKey: "k1",
}
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("initiate by quote_ref failed: %v", err)
}
if resp == nil || resp.GetPayment() == nil {
t.Fatalf("expected payment response")
}
if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" {
t.Fatalf("expected intent amount to be resolved from quote")
}
if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" {
t.Fatalf("expected last quote_ref to be set from stored quote")
}
}
// --- test doubles --- // --- test doubles ---
type stubRepo struct { type stubRepo struct {

View File

@@ -158,6 +158,24 @@ type ExecutionRefs struct {
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"` FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
} }
// ExecutionStep describes a planned or executed payment step for reporting.
type ExecutionStep struct {
Code string `bson:"code,omitempty" json:"code,omitempty"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
Amount *moneyv1.Money `bson:"amount,omitempty" json:"amount,omitempty"`
NetworkFee *moneyv1.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"`
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// ExecutionPlan captures the ordered list of steps to execute a payment.
type ExecutionPlan struct {
Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"`
TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"`
}
// Payment persists orchestrated payment lifecycle. // Payment persists orchestrated payment lifecycle.
type Payment struct { type Payment struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
@@ -171,6 +189,7 @@ type Payment struct {
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"` FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
ExecutionPlan *ExecutionPlan `bson:"executionPlan,omitempty" json:"executionPlan,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"` CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
} }
@@ -218,6 +237,23 @@ func (p *Payment) Normalize() {
p.Execution.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef) p.Execution.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef)
p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef) p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef)
} }
if p.ExecutionPlan != nil {
for _, step := range p.ExecutionPlan.Steps {
if step == nil {
continue
}
step.Code = strings.TrimSpace(step.Code)
step.Description = strings.TrimSpace(step.Description)
step.SourceWalletRef = strings.TrimSpace(step.SourceWalletRef)
step.DestinationRef = strings.TrimSpace(step.DestinationRef)
step.TransferRef = strings.TrimSpace(step.TransferRef)
if step.Metadata != nil {
for k, v := range step.Metadata {
step.Metadata[k] = strings.TrimSpace(v)
}
}
}
}
} }
func normalizeEndpoint(ep *PaymentEndpoint) { func normalizeEndpoint(ep *PaymentEndpoint) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ require (
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
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
) )
@@ -93,6 +93,6 @@ 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.5.0 // indirect golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -267,12 +267,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 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/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

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

View File

@@ -7,13 +7,15 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "common/money/v1/money.proto"; import "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto"; import "common/pagination/v1/cursor.proto";
import "common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets. // Supported blockchain networks for the managed wallets.
enum ChainNetwork { enum ChainNetwork {
CHAIN_NETWORK_UNSPECIFIED = 0; CHAIN_NETWORK_UNSPECIFIED = 0;
CHAIN_NETWORK_ETHEREUM_MAINNET = 1; CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
CHAIN_NETWORK_ARBITRUM_ONE = 2; CHAIN_NETWORK_ARBITRUM_ONE = 2;
CHAIN_NETWORK_OTHER_EVM = 3; CHAIN_NETWORK_TRON_MAINNET = 4;
CHAIN_NETWORK_TRON_NILE = 5;
} }
enum ManagedWalletStatus { enum ManagedWalletStatus {
@@ -57,6 +59,7 @@ message ManagedWallet {
map<string, string> metadata = 7; map<string, string> metadata = 7;
google.protobuf.Timestamp created_at = 8; google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9; google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
} }
message CreateManagedWalletRequest { message CreateManagedWalletRequest {
@@ -65,6 +68,7 @@ message CreateManagedWalletRequest {
string owner_ref = 3; string owner_ref = 3;
Asset asset = 4; Asset asset = 4;
map<string, string> metadata = 5; map<string, string> metadata = 5;
common.describable.v1.Describable describable = 6;
} }
message CreateManagedWalletResponse { message CreateManagedWalletResponse {
@@ -96,6 +100,7 @@ message WalletBalance {
common.money.v1.Money pending_inbound = 2; common.money.v1.Money pending_inbound = 2;
common.money.v1.Money pending_outbound = 3; common.money.v1.Money pending_outbound = 3;
google.protobuf.Timestamp calculated_at = 4; google.protobuf.Timestamp calculated_at = 4;
common.money.v1.Money native_available = 5;
} }
message GetWalletBalanceRequest { message GetWalletBalanceRequest {
@@ -184,6 +189,32 @@ message EstimateTransferFeeResponse {
string estimation_context = 2; string estimation_context = 2;
} }
message ComputeGasTopUpRequest {
string wallet_ref = 1;
common.money.v1.Money estimated_total_fee = 2;
}
message ComputeGasTopUpResponse {
common.money.v1.Money topup_amount = 1;
bool cap_hit = 2;
}
message EnsureGasTopUpRequest {
string idempotency_key = 1;
string organization_ref = 2;
string source_wallet_ref = 3;
string target_wallet_ref = 4;
common.money.v1.Money estimated_total_fee = 5;
map<string, string> metadata = 6;
string client_reference = 7;
}
message EnsureGasTopUpResponse {
common.money.v1.Money topup_amount = 1;
bool cap_hit = 2;
Transfer transfer = 3;
}
message WalletDepositObservedEvent { message WalletDepositObservedEvent {
string deposit_ref = 1; string deposit_ref = 1;
string wallet_ref = 2; string wallet_ref = 2;
@@ -213,4 +244,6 @@ service ChainGatewayService {
rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse); rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse);
rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse); rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse);
rpc ComputeGasTopUp(ComputeGasTopUpRequest) returns (ComputeGasTopUpResponse);
rpc EnsureGasTopUp(EnsureGasTopUpRequest) returns (EnsureGasTopUpResponse);
} }

View File

@@ -141,6 +141,22 @@ message ExecutionRefs {
string fee_transfer_ref = 6; string fee_transfer_ref = 6;
} }
message ExecutionStep {
string code = 1;
string description = 2;
common.money.v1.Money amount = 3;
common.money.v1.Money network_fee = 4;
string source_wallet_ref = 5;
string destination_ref = 6;
string transfer_ref = 7;
map<string, string> metadata = 8;
}
message ExecutionPlan {
repeated ExecutionStep steps = 1;
common.money.v1.Money total_network_fee = 2;
}
// Card payout gateway tracking info. // Card payout gateway tracking info.
message CardPayout { message CardPayout {
string payout_ref = 1; string payout_ref = 1;
@@ -166,6 +182,7 @@ message Payment {
google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11; google.protobuf.Timestamp updated_at = 11;
CardPayout card_payout = 12; CardPayout card_payout = 12;
ExecutionPlan execution_plan = 13;
} }
message QuotePaymentRequest { message QuotePaymentRequest {

View File

@@ -80,7 +80,7 @@ api:
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true insecure: true
default_asset: default_asset:
chain: ARBITRUM_ONE chain: TRON_MAINNET
token_symbol: USDT token_symbol: USDT
contract_address: "" contract_address: ""
ledger: ledger:

View File

@@ -14,7 +14,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.6 github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
@@ -139,6 +139,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-20251213004720-97cd9d5aeac2 // 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
) )

View File

@@ -32,8 +32,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA= github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
@@ -359,12 +359,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 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/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View File

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

View File

@@ -65,7 +65,7 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
t.Run("external chain", func(t *testing.T) { t.Run("external chain", func(t *testing.T) {
payload := ExternalChainEndpoint{ payload := ExternalChainEndpoint{
Asset: &Asset{ Asset: &Asset{
Chain: ChainNetworkOtherEVM, Chain: ChainNetworkEthereumMainnet,
TokenSymbol: "ETH", TokenSymbol: "ETH",
}, },
Address: "0x123", Address: "0x123",
@@ -364,7 +364,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
func TestLegacyEndpointRoundTrip(t *testing.T) { func TestLegacyEndpointRoundTrip(t *testing.T) {
legacy := &LegacyPaymentEndpoint{ legacy := &LegacyPaymentEndpoint{
ExternalChain: &ExternalChainEndpoint{ ExternalChain: &ExternalChainEndpoint{
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"}, Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "DAI", ContractAddress: "0xdef"},
Address: "0x123", Address: "0x123",
Memo: "memo", Memo: "memo",
}, },

View File

@@ -7,7 +7,6 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
@@ -20,11 +19,6 @@ type FeeLine struct {
Meta map[string]string `json:"meta,omitempty"` Meta map[string]string `json:"meta,omitempty"`
} }
type NetworkFee struct {
NetworkFee *model.Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,omitempty"`
}
type FxQuote struct { type FxQuote struct {
QuoteRef string `json:"quoteRef,omitempty"` QuoteRef string `json:"quoteRef,omitempty"`
BaseCurrency string `json:"baseCurrency,omitempty"` BaseCurrency string `json:"baseCurrency,omitempty"`
@@ -45,7 +39,6 @@ type PaymentQuote struct {
ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"` ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"` ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"`
FeeLines []FeeLine `json:"feeLines,omitempty"` FeeLines []FeeLine `json:"feeLines,omitempty"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"` FxQuote *FxQuote `json:"fxQuote,omitempty"`
} }
@@ -53,7 +46,6 @@ type PaymentQuoteAggregate struct {
DebitAmounts []*model.Money `json:"debitAmounts,omitempty"` DebitAmounts []*model.Money `json:"debitAmounts,omitempty"`
ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"` ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"`
ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"` ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"`
NetworkFeeTotals []*model.Money `json:"networkFeeTotals,omitempty"`
} }
type PaymentQuotes struct { type PaymentQuotes struct {
@@ -146,16 +138,6 @@ func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
return result return result
} }
func toNetworkFee(n *chainv1.EstimateTransferFeeResponse) *NetworkFee {
if n == nil {
return nil
}
return &NetworkFee{
NetworkFee: toMoney(n.GetNetworkFee()),
EstimationContext: n.GetEstimationContext(),
}
}
func toFxQuote(q *oraclev1.Quote) *FxQuote { func toFxQuote(q *oraclev1.Quote) *FxQuote {
if q == nil { if q == nil {
return nil return nil
@@ -192,7 +174,6 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()), ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()), ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
FeeLines: toFeeLines(q.GetFeeLines()), FeeLines: toFeeLines(q.GetFeeLines()),
NetworkFee: toNetworkFee(q.GetNetworkFee()),
FxQuote: toFxQuote(q.GetFxQuote()), FxQuote: toFxQuote(q.GetFxQuote()),
} }
} }
@@ -205,7 +186,6 @@ func toPaymentQuoteAggregate(q *orchestratorv1.PaymentQuoteAggregate) *PaymentQu
DebitAmounts: toMoneyList(q.GetDebitAmounts()), DebitAmounts: toMoneyList(q.GetDebitAmounts()),
ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()), ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()),
ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()), ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()),
NetworkFeeTotals: toMoneyList(q.GetNetworkFeeTotals()),
} }
} }

View File

@@ -2,6 +2,7 @@ package sresponse
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
@@ -26,6 +27,8 @@ type wallet struct {
DepositAddress string `json:"depositAddress"` DepositAddress string `json:"depositAddress"`
Status string `json:"status"` Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"`
} }
@@ -76,10 +79,31 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
token := "" token := ""
contract := "" contract := ""
if asset != nil { if asset != nil {
chain = asset.GetChain().String() chain = chainNetworkValue(asset.GetChain())
token = asset.GetTokenSymbol() token = asset.GetTokenSymbol()
contract = asset.GetContractAddress() contract = asset.GetContractAddress()
} }
name := ""
if d := w.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(w.GetMetadata()["name"])
}
if name == "" {
name = w.GetWalletRef()
}
var description *string
if d := w.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
description = &trimmed
}
}
return wallet{ return wallet{
WalletRef: w.GetWalletRef(), WalletRef: w.GetWalletRef(),
OrganizationRef: w.GetOrganizationRef(), OrganizationRef: w.GetOrganizationRef(),
@@ -92,6 +116,8 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
DepositAddress: w.GetDepositAddress(), DepositAddress: w.GetDepositAddress(),
Status: w.GetStatus().String(), Status: w.GetStatus().String(),
Metadata: w.GetMetadata(), Metadata: w.GetMetadata(),
Name: name,
Description: description,
CreatedAt: tsToString(w.GetCreatedAt()), CreatedAt: tsToString(w.GetCreatedAt()),
UpdatedAt: tsToString(w.GetUpdatedAt()), UpdatedAt: tsToString(w.GetUpdatedAt()),
} }
@@ -115,3 +141,15 @@ func tsToString(ts *timestamppb.Timestamp) string {
} }
return ts.AsTime().UTC().Format(time.RFC3339) return ts.AsTime().UTC().Format(time.RFC3339)
} }
func chainNetworkValue(chain chainv1.ChainNetwork) string {
name := chain.String()
if !strings.HasPrefix(name, "CHAIN_NETWORK_") {
return "unspecified"
}
trimmed := strings.TrimPrefix(name, "CHAIN_NETWORK_")
if trimmed == "" {
return "unspecified"
}
return strings.ToLower(trimmed)
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse" "github.com/tech/sendico/server/interface/api/sresponse"
@@ -20,7 +21,7 @@ import (
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc { func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r) orgRef, err := a.oph.GetRef(r)
if err != nil { if err != nil {
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r))) a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
} }
@@ -76,7 +77,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
resp, err := a.client.InitiatePayment(ctx, req) resp, err := a.client.InitiatePayment(ctx, req)
if err != nil { if err != nil {
a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Auto(a.logger, a.Name(), err) return response.Auto(a.logger, a.Name(), err)
} }

View File

@@ -44,7 +44,7 @@ services:
NATS_PORT: ${NATS_PORT} NATS_PORT: ${NATS_PORT}
NATS_USER: ${NATS_USER} NATS_USER: ${NATS_USER}
NATS_PASSWORD: ${NATS_PASSWORD} NATS_PASSWORD: ${NATS_PASSWORD}
CHAIN_GATEWAY_ARBITRUM_RPC_URL: ${CHAIN_GATEWAY_ARBITRUM_RPC_URL} CHAIN_GATEWAY_RPC_URL: ${CHAIN_GATEWAY_RPC_URL}
CHAIN_GATEWAY_SERVICE_WALLET_KEY: ${CHAIN_GATEWAY_SERVICE_WALLET_KEY} CHAIN_GATEWAY_SERVICE_WALLET_KEY: ${CHAIN_GATEWAY_SERVICE_WALLET_KEY}
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS: ${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS} CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS: ${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}
VAULT_TOKEN_FILE: /run/vault/token VAULT_TOKEN_FILE: /run/vault/token

View File

@@ -18,7 +18,7 @@ SERVICE_NAMES="${CHAIN_GATEWAY_SERVICE_NAME}"
REQUIRED_SECRETS=( REQUIRED_SECRETS=(
CHAIN_GATEWAY_MONGO_USER CHAIN_GATEWAY_MONGO_USER
CHAIN_GATEWAY_MONGO_PASSWORD CHAIN_GATEWAY_MONGO_PASSWORD
CHAIN_GATEWAY_ARBITRUM_RPC_URL CHAIN_GATEWAY_RPC_URL
CHAIN_GATEWAY_SERVICE_WALLET_KEY CHAIN_GATEWAY_SERVICE_WALLET_KEY
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
CHAIN_GATEWAY_VAULT_ROLE_ID CHAIN_GATEWAY_VAULT_ROLE_ID
@@ -46,7 +46,7 @@ b64enc() {
CHAIN_GATEWAY_MONGO_USER_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_USER}")" CHAIN_GATEWAY_MONGO_USER_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_USER}")"
CHAIN_GATEWAY_MONGO_PASSWORD_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_PASSWORD}")" CHAIN_GATEWAY_MONGO_PASSWORD_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_PASSWORD}")"
CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64="$(b64enc "${CHAIN_GATEWAY_ARBITRUM_RPC_URL}")" CHAIN_GATEWAY_RPC_URL_B64="$(b64enc "${CHAIN_GATEWAY_RPC_URL}")"
CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_KEY}")" CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_KEY}")"
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}")" CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}")"
CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$(b64enc "${CHAIN_GATEWAY_VAULT_ROLE_ID}")" CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$(b64enc "${CHAIN_GATEWAY_VAULT_ROLE_ID}")"
@@ -84,7 +84,7 @@ ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \
SERVICES_LINE="$SERVICES_LINE" \ SERVICES_LINE="$SERVICES_LINE" \
CHAIN_GATEWAY_MONGO_USER_B64="$CHAIN_GATEWAY_MONGO_USER_B64" \ CHAIN_GATEWAY_MONGO_USER_B64="$CHAIN_GATEWAY_MONGO_USER_B64" \
CHAIN_GATEWAY_MONGO_PASSWORD_B64="$CHAIN_GATEWAY_MONGO_PASSWORD_B64" \ CHAIN_GATEWAY_MONGO_PASSWORD_B64="$CHAIN_GATEWAY_MONGO_PASSWORD_B64" \
CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64="$CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64" \ CHAIN_GATEWAY_RPC_URL_B64="$CHAIN_GATEWAY_RPC_URL_B64" \
CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64" \ CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64" \
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64" \ CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64" \
CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$CHAIN_GATEWAY_VAULT_ROLE_ID_B64" \ CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$CHAIN_GATEWAY_VAULT_ROLE_ID_B64" \
@@ -135,7 +135,7 @@ decode_b64() {
CHAIN_GATEWAY_MONGO_USER="$(decode_b64 "$CHAIN_GATEWAY_MONGO_USER_B64")" CHAIN_GATEWAY_MONGO_USER="$(decode_b64 "$CHAIN_GATEWAY_MONGO_USER_B64")"
CHAIN_GATEWAY_MONGO_PASSWORD="$(decode_b64 "$CHAIN_GATEWAY_MONGO_PASSWORD_B64")" CHAIN_GATEWAY_MONGO_PASSWORD="$(decode_b64 "$CHAIN_GATEWAY_MONGO_PASSWORD_B64")"
CHAIN_GATEWAY_ARBITRUM_RPC_URL="$(decode_b64 "$CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64")" CHAIN_GATEWAY_RPC_URL="$(decode_b64 "$CHAIN_GATEWAY_RPC_URL_B64")"
CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64")" CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64")"
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64")" CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64")"
CHAIN_GATEWAY_VAULT_ROLE_ID="$(decode_b64 "$CHAIN_GATEWAY_VAULT_ROLE_ID_B64")" CHAIN_GATEWAY_VAULT_ROLE_ID="$(decode_b64 "$CHAIN_GATEWAY_VAULT_ROLE_ID_B64")"
@@ -145,7 +145,7 @@ NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")"
NATS_URL="$(decode_b64 "$NATS_URL_B64")" NATS_URL="$(decode_b64 "$NATS_URL_B64")"
export CHAIN_GATEWAY_MONGO_USER CHAIN_GATEWAY_MONGO_PASSWORD export CHAIN_GATEWAY_MONGO_USER CHAIN_GATEWAY_MONGO_PASSWORD
export CHAIN_GATEWAY_ARBITRUM_RPC_URL export CHAIN_GATEWAY_RPC_URL
export CHAIN_GATEWAY_SERVICE_WALLET_KEY CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS export CHAIN_GATEWAY_SERVICE_WALLET_KEY CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
export CHAIN_GATEWAY_VAULT_ROLE_ID CHAIN_GATEWAY_VAULT_SECRET_ID export CHAIN_GATEWAY_VAULT_ROLE_ID CHAIN_GATEWAY_VAULT_SECRET_ID
export NATS_USER NATS_PASSWORD NATS_URL export NATS_USER NATS_PASSWORD NATS_URL

View File

@@ -56,7 +56,7 @@ CHAIN_GATEWAY_VAULT_SECRET_PATH="${CHAIN_GATEWAY_VAULT_SECRET_PATH:?missing CHAI
export CHAIN_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" user)" export CHAIN_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" user)"
export CHAIN_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" password)" export CHAIN_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" password)"
export CHAIN_GATEWAY_ARBITRUM_RPC_URL="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_RPC_SECRET_PATH}" arbitrum_rpc_url)" export CHAIN_GATEWAY_RPC_URL="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_RPC_SECRET_PATH}" tron_rpc_url)"
export CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" private_key)" export CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" private_key)"
export CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" address || true)" export CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" address || true)"

View File

@@ -11,7 +11,9 @@ class ChangePassword {
@JsonKey(name: 'new') @JsonKey(name: 'new')
final String newPassword; final String newPassword;
const ChangePassword({required this.oldPassword, required this.newPassword}); final String deviceId;
const ChangePassword({required this.oldPassword, required this.newPassword, required this.deviceId});
factory ChangePassword.fromJson(Map<String, dynamic> json) => _$ChangePasswordFromJson(json); factory ChangePassword.fromJson(Map<String, dynamic> json) => _$ChangePasswordFromJson(json);
Map<String, dynamic> toJson() => _$ChangePasswordToJson(this); Map<String, dynamic> toJson() => _$ChangePasswordToJson(this);

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