56 Commits

Author SHA1 Message Date
69fdbf4e95 Merge pull request 'Fixed payment information form in address recipient book and fixed some headers' (#163) from SEND016 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #163
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-25 13:18:37 +00:00
Arseni
be10839e3a Fixed payment information form in address recipient book and fixed some headers 2025-12-25 15:10:20 +03:00
d530af43a1 Merge pull request 'EVM, ARB, ETH gas top up policies + tron config change' (#162) from tron-161 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #162
2025-12-25 11:54:02 +00:00
Stephan D
aa673fb26d EVM, ARB, ETH gas top up policies + tron config change 2025-12-25 12:52:34 +01:00
d978e24a9d Merge pull request 'Gas topup limits' (#160) from tron-159 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
Reviewed-on: #160
2025-12-25 11:29:45 +00:00
Stephan D
31d93e5113 Gas topup limits 2025-12-25 12:26:24 +01:00
f02f3449f3 Merge pull request 'gas tanking before transaction' (#158) from tron-157 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #158
2025-12-25 10:30:05 +00:00
Stephan D
d46822b9bb gas tanking before transaction 2025-12-25 11:25:13 +01:00
0505b2314e Merge pull request 'quotation provider now uses payment methods as source for quotation' (#156) from flow-153 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #156
2025-12-24 19:40:15 +00:00
Stephan D
407e704352 quotation provider now uses payment methods as source for quotation 2025-12-24 20:39:17 +01:00
4251dfb2c6 Merge pull request 'added wallet source to quotation preparation' (#152) from wallet-151 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #152
2025-12-24 19:00:24 +00:00
Stephan D
e0820c47c2 added wallet source to quotation preparation 2025-12-24 19:59:50 +01:00
68b82cbca2 Merge pull request 'chain network name display fixed' (#147) from wallet-147 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #147
2025-12-24 17:18:13 +00:00
Stephan D
9e6d530385 chain network name display fixed 2025-12-24 18:17:35 +01:00
5836292adb Merge pull request 'Added Last Name display and made it editable' (#145) from SEND015 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #145
2025-12-24 16:08:40 +00:00
0c6229331f Merge pull request 'Password field checks for match with old password from db and check so that new password feild matches with the confirm password field' (#143) from SEND013 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #143
2025-12-24 16:07:58 +00:00
8cb6a64f2b Merge pull request 'Got rid of bools in 2fa provider' (#144) from SEND014 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #144
2025-12-24 16:07:07 +00:00
Arseni
4453dab366 Added Last Name display and made it editable 2025-12-24 18:48:33 +03:00
Arseni
512f25f74f Got rid of bools in 2fa provider 2025-12-24 16:26:22 +03:00
Arseni
43020f3eb6 Password field checks for match with old password from db and check so that new password feild matches with the confirm password field 2025-12-24 16:18:52 +03:00
964e90767d Merge pull request 'tron refactoring' (#142) from tron-150 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
Reviewed-on: #142
2025-12-24 12:20:46 +00:00
Stephan D
03cd2f4784 tron refactoring 2025-12-24 13:20:25 +01:00
2d735aa7f5 Merge pull request 'hex parser + test' (#141) from tron-148 into main
Some checks failed
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #141
2025-12-24 02:53:38 +00:00
Stephan D
342dd5328f hex parser + test 2025-12-24 03:53:20 +01:00
915ed66b08 Merge pull request 'fixed big int reader' (#140) from tron-146 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #140
2025-12-24 02:15:36 +00:00
Stephan D
fe73b3078a fixed big int reader 2025-12-24 03:15:12 +01:00
76204822e7 Merge pull request 'fixed proto message' (#139) from tron-142 into main
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #139
2025-12-24 01:57:36 +00:00
Stephan D
77c205f9b2 fixed proto message 2025-12-24 02:57:15 +01:00
6a29dc8907 Merge pull request 'compilation fix' (#138) from tron-144 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #138
2025-12-24 01:17:24 +00:00
Stephan D
8f1f279792 compilation fix 2025-12-24 02:17:01 +01:00
1f0b54d590 Merge pull request 'extended logging + timeout setting' (#137) from tron-140 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
Reviewed-on: #137
2025-12-24 01:00:20 +00:00
Stephan D
cefb9706f9 extended logging + timeout setting 2025-12-24 01:59:37 +01:00
79b7899658 Merge pull request 'refactored initialization' (#136) from tron-138 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #136
2025-12-24 00:32:09 +00:00
Stephan D
c941319c4e refactored initialization 2025-12-24 01:31:43 +01:00
e6626600cc Merge pull request 'extra logging' (#135) from tron-136 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #135
2025-12-23 17:36:51 +00:00
Stephan D
e74c06e87a extra logging 2025-12-23 18:36:22 +01:00
c3647bfc46 Merge pull request 'version bump' (#134) from tron-136 into main
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #134
2025-12-23 16:43:46 +00:00
Stephan D
3ff81038a9 version bump 2025-12-23 17:43:20 +01:00
d6d9d47e67 Merge pull request 'Added new tron networks' (#133) from tron-134 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
Reviewed-on: #133
2025-12-23 16:23:50 +00:00
Stephan D
034eb943e2 Added new tron networks 2025-12-23 17:21:58 +01:00
93bd0bf002 Merge pull request 'fixed config + improved logging' (#132) from tron-132 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #132
2025-12-23 15:28:27 +00:00
Stephan D
946bfa217c fixed config + improved logging 2025-12-23 16:26:06 +01:00
318255405b Merge pull request 'Migration to TRON chain' (#130) from tron-130 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
Reviewed-on: #130
2025-12-23 14:42:30 +00:00
Stephan D
19d4ee1d33 Migration to TRON chain 2025-12-23 15:42:07 +01:00
bc6a56c129 Merge pull request 'Implemented cooldown before User is able to resend confirmation code for 2fa' (#128) from SEND012 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #128
2025-12-23 12:56:07 +00:00
Arseni
ec54579921 Implemented cooldown before User is able to resend confirmation code for 2fa 2025-12-23 14:56:47 +03:00
1ed76f7243 Merge pull request 'Fixed tokens revocation' (#127) from devid-122 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #127
2025-12-22 20:25:03 +00:00
Stephan D
6527d183ec Fixed tokens revokation 2025-12-22 21:22:51 +01:00
41b0dec460 Merge pull request 'Fixes for Settings Page' (#123) from SEND011 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #123
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-22 19:26:44 +00:00
Arseni
d26ba84094 Moved the AccountName widgets to pull providers from context 2025-12-22 21:38:26 +03:00
Arseni
4073c8819c Fixed imports 2025-12-22 21:12:21 +03:00
Arseni
47ada0691c Fixes for Settings Page 2025-12-22 21:09:58 +03:00
97c67670e5 Merge pull request 'added fix for active indexed tokens + improved data structure for wallet description' (#122) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #122
2025-12-22 17:30:48 +00:00
Stephan D
dfad7fb335 added fix for active indexed tokens + improved data structure for wallet description 2025-12-22 18:30:15 +01:00
41abf723e6 Merge pull request 'multiquote service' (#117) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #117
2025-12-17 20:56:28 +00:00
Stephan D
2d6586430f multiquote service 2025-12-17 21:56:07 +01:00
195 changed files with 6712 additions and 1236 deletions

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -49,6 +49,6 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -49,7 +49,7 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -13,7 +13,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

@@ -16,6 +16,8 @@ type Fake struct {
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
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
}
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
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 {
if f.CloseFn != nil {
return f.CloseFn()

View File

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

View File

@@ -15,14 +15,14 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-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/bits-and-blooms/bitset v1.24.4 // 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/text v0.32.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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
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 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM=
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/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,14 +2,18 @@ package serverimp
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage"
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
@@ -30,6 +34,8 @@ type Imp struct {
config *config
app *grpcapp.App[storage.Repository]
rpcClients *rpcclient.Clients
}
type config struct {
@@ -41,11 +47,12 @@ type config struct {
}
type chainConfig struct {
Name string `yaml:"name"`
RPCURLEnv string `yaml:"rpc_url_env"`
ChainID uint64 `yaml:"chain_id"`
NativeToken string `yaml:"native_token"`
Tokens []tokenConfig `yaml:"tokens"`
Name string `yaml:"name"`
RPCURLEnv string `yaml:"rpc_url_env"`
ChainID uint64 `yaml:"chain_id"`
NativeToken string `yaml:"native_token"`
Tokens []tokenConfig `yaml:"tokens"`
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
}
type serviceWalletConfig struct {
@@ -61,6 +68,19 @@ type tokenConfig struct {
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.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
@@ -84,6 +104,9 @@ func (i *Imp) Shutdown() {
defer cancel()
i.app.Shutdown(ctx)
if i.rpcClients != nil {
i.rpcClients.Close()
}
}
func (i *Imp) Start() error {
@@ -98,20 +121,34 @@ func (i *Imp) Start() error {
}
cl := i.logger.Named("config")
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
if err != nil {
i.logger.Error("invalid chain network configuration", zap.Error(err))
return err
}
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
if err != nil {
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
return err
}
i.rpcClients = rpcClients
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
if err != nil {
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) {
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
opts := []gatewayservice.Option{
gatewayservice.WithNetworks(networkConfigs),
gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithTransferExecutor(executor),
gatewayservice.WithRPCClients(rpcClients),
gatewayservice.WithDriverRegistry(driverRegistry),
gatewayservice.WithSettings(cfg.Settings),
}
return gatewayservice.NewService(logger, repo, producer, opts...), nil
@@ -157,7 +194,7 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil
}
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" {
@@ -166,7 +203,8 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
}
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" {
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
}
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
for _, token := range chain.Tokens {
@@ -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{
Name: chain.Name,
RPCURL: rpcURL,
ChainID: chain.ChainID,
NativeToken: chain.NativeToken,
TokenConfigs: contracts,
Name: chain.Name,
RPCURL: rpcURL,
ChainID: chain.ChainID,
NativeToken: chain.NativeToken,
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 {

View File

@@ -23,6 +23,8 @@ type Registry struct {
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
}
type RegistryDeps struct {
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
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 (
"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/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
@@ -11,9 +14,11 @@ import (
type Deps struct {
Logger mlogger.Logger
Networks map[string]shared.Network
Drivers *drivers.Registry
Networks *rpcclient.Registry
Storage storage.Repository
Clock clockpkg.Clock
RPCTimeout time.Duration
EnsureRepository func(context.Context) error
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
}

View File

@@ -43,8 +43,22 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
deps.Logger.Warn("destination external address missing")
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{
ExternalAddress: strings.ToLower(external),
ExternalAddress: normalized,
Memo: strings.TrimSpace(dest.GetMemo()),
}, nil
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
if chainErr != nil {
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
@@ -74,37 +74,47 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
}
calculatedAt := c.now()
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
Balance: onChainBalanceToProto(balance, calculatedAt),
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
})
}
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
if balance == nil {
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
if balance == nil && native == nil {
return nil
}
zero := zeroMoney(balance.Currency)
currency := ""
if balance != nil {
currency = balance.Currency
}
zero := zeroMoney(currency)
return &chainv1.WalletBalance{
Available: balance,
NativeAvailable: native,
PendingInbound: zero,
PendingOutbound: zero,
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
}
}
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
if available == nil {
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
if available == nil && nativeAvailable == nil {
return
}
record := &model.WalletBalance{
WalletRef: walletRef,
Available: shared.CloneMoney(available),
PendingInbound: zeroMoney(available.Currency),
PendingOutbound: zeroMoney(available.Currency),
NativeAvailable: shared.CloneMoney(nativeAvailable),
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 {
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/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
@@ -59,11 +60,20 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
networkCfg, ok := c.deps.Networks[chainKey]
networkCfg, ok := c.deps.Networks.Network(chainKey)
if !ok {
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
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()))
if tokenSymbol == "" {
@@ -72,10 +82,12 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
}
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
if contractAddress == "" {
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
if contractAddress == "" {
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
if contractAddress == "" {
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
}
}
}
@@ -94,8 +106,37 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
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"))
}
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{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey,
WalletRef: walletRef,
OrganizationRef: organizationRef,
@@ -103,10 +144,13 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
Network: chainKey,
TokenSymbol: tokenSymbol,
ContractAddress: contractAddress,
DepositAddress: strings.ToLower(keyInfo.Address),
DepositAddress: depositAddress,
KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive,
Metadata: shared.CloneMetadata(req.GetMetadata()),
Metadata: metadata,
}
if description != nil {
wallet.Describable.Description = description
}
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

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

View File

@@ -2,123 +2,61 @@ package wallet
import (
"context"
"math/big"
"fmt"
"strings"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
return nil, merrors.Internal("network rpc url is not configured")
func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
logger := deps.Logger
if wallet == nil {
return nil, nil, merrors.InvalidArgument("wallet is required")
}
contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" || !common.IsHexAddress(contract) {
return nil, merrors.InvalidArgument("invalid contract address")
if deps.Networks == nil {
return nil, nil, merrors.Internal("rpc clients not initialised")
}
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
return nil, merrors.InvalidArgument("invalid wallet address")
if deps.Drivers == nil {
return nil, nil, merrors.Internal("chain drivers not configured")
}
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
}
defer client.Close()
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(contract)
walletAddr := common.HexToAddress(wallet.DepositAddress)
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
return nil, err
}
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
if err != nil {
return nil, err
}
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
}
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
data, err := tokenABI.Pack("decimals")
if err != nil {
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return 0, merrors.Internal("decimals call failed: " + err.Error())
}
values, err := tokenABI.Unpack("decimals", out)
if err != nil || len(values) == 0 {
return 0, merrors.Internal("failed to unpack decimals")
}
if val, ok := values[0].(uint8); ok {
return val, nil
}
return 0, merrors.Internal("decimals returned unexpected type")
}
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
data, err := tokenABI.Pack("balanceOf", wallet)
if err != nil {
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
}
msg := ethereum.CallMsg{To: &token, Data: data}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
}
values, err := tokenABI.Unpack("balanceOf", out)
if err != nil || len(values) == 0 {
return nil, merrors.Internal("failed to unpack balanceOf")
}
raw, ok := values[0].(*big.Int)
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
network, ok := deps.Networks.Network(networkKey)
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 = `
[
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [{ "name": "", "type": "uint8" }],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [{ "name": "_owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "balance", "type": "uint256" }],
"payable": false,
"stateMutability": "view",
"type": "function"
chainDriver, err := deps.Drivers.Driver(networkKey)
if err != nil {
logger.Warn("Chain driver not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.Error(err),
)
return nil, nil, merrors.InvalidArgument("unsupported chain")
}
]`
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
import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress,
}
name := strings.TrimSpace(wallet.Name)
if name == "" {
name = strings.TrimSpace(wallet.Metadata["name"])
}
if name == "" {
name = wallet.WalletRef
}
description := ""
switch {
case wallet.Description != nil:
description = strings.TrimSpace(*wallet.Description)
default:
description = strings.TrimSpace(wallet.Metadata["description"])
}
desc := &describablev1.Describable{Name: name}
if description != "" {
desc.Description = &description
}
return &chainv1.ManagedWallet{
WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef,
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
Metadata: shared.CloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
Describable: desc,
}
}
@@ -35,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
}
return &chainv1.WalletBalance{
Available: shared.CloneMoney(balance.Available),
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
PendingInbound: shared.CloneMoney(balance.PendingInbound),
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import (
"strings"
"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"
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.
func WithTransferExecutor(executor TransferExecutor) Option {
// WithRPCClients configures pre-initialised RPC clients.
func WithRPCClients(clients *rpcclient.Clients) Option {
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.
func WithClock(clk clockpkg.Clock) Option {
return func(s *Service) {

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import (
"google.golang.org/grpc/status"
"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/storage"
"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())
}
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) {
svc, repo := newTestService(t)
ctx := context.Background()
@@ -143,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
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 ----
type inMemoryRepository struct {
@@ -526,18 +577,23 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
return int64(requested)
}
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
repo := newInMemoryRepository()
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,
WithKeyManager(&fakeKeyManager{}),
WithNetworks([]shared.Network{{
Name: "ethereum_mainnet",
TokenConfigs: []shared.TokenContract{
{Symbol: "USDC", ContractAddress: "0xusdc"},
},
}}),
WithNetworks(networks),
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
WithDriverRegistry(driverRegistry),
)
return svc, repo
}

View File

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

View File

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

View File

@@ -121,11 +121,12 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name string
RPCURL string
ChainID uint64
NativeToken string
TokenConfigs []TokenContract
Name string
RPCURL string
ChainID uint64
NativeToken string
TokenConfigs []TokenContract
GasTopUpPolicy *GasTopUpPolicy
}
// 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"
"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/pkg/merrors"
"go.uber.org/zap"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
)
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
if s.executor == nil {
if s.drivers == nil {
return
}
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
defer cancel()
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
}
}(transferRef, sourceWalletRef, network)
}
@@ -44,13 +44,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
}
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
@@ -62,7 +69,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
if err != nil {
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))
@@ -83,7 +90,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
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 != "" {
wallet, err := s.storage.Wallets().Get(ctx, ref)
if err != nil {
@@ -92,10 +99,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
if strings.TrimSpace(wallet.DepositAddress) == "" {
return "", merrors.Internal("destination wallet missing deposit address")
}
return wallet.DepositAddress, nil
return chainDriver.NormalizeAddress(wallet.DepositAddress)
}
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
return strings.ToLower(addr), nil
return chainDriver.NormalizeAddress(addr)
}
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"
"github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
@@ -19,7 +20,8 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"`
storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -45,6 +47,7 @@ type WalletBalance struct {
WalletRef string `bson:"walletRef" json:"walletRef"`
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"`
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
@@ -77,10 +80,19 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
m.KeyReference = strings.TrimSpace(m.KeyReference)
}
@@ -88,3 +100,31 @@ func (m *ManagedWallet) Normalize() {
func (b *WalletBalance) Normalize() {
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

@@ -11,7 +11,7 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -51,5 +51,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -52,7 +52,7 @@ require (
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -59,8 +59,8 @@ oracle:
card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"
funding_address: "TXtjmjF99MhMdaMQrLopzcQ8cSBRLq5co8"
fee_wallet_ref: "694c124fd76f9f811ac57134"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -24,7 +24,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -62,5 +62,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -59,6 +59,7 @@ type clientConfig struct {
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
FeeWalletRef string `yaml:"fee_wallet_ref"`
}
func (c clientConfig) address() string {
@@ -323,6 +324,7 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
}
}
return result

View File

@@ -7,13 +7,21 @@ import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
const defaultCardGateway = "monetix"
const (
defaultCardGateway = "monetix"
stepCodeGasTopUp = "gas_top_up"
stepCodeFundingTransfer = "funding_transfer"
stepCodeCardPayout = "card_payout"
stepCodeFeeTransfer = "fee_transfer"
)
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
@@ -54,24 +62,214 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
if err != nil {
return err
}
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
fundingAddress := strings.TrimSpace(route.FundingAddress)
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
amount := cloneMoney(intent.Amount)
if amount == nil {
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: amount is required")
}
payoutAmount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
feeMoney := (*moneyv1.Money)(nil)
if quote != nil {
feeMoney = quote.GetExpectedFeeTotal()
}
if feeMoney == nil && payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
feeDecimal := decimal.Zero
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: fee currency is required")
}
feeDecimal, err = decimalFromMoney(feeMoney)
if err != nil {
return err
}
}
feeRequired := feeDecimal.IsPositive()
fundingDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}
fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount)
if err != nil {
return err
}
var feeTransferFee *moneyv1.Money
if feeRequired {
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
}
feeDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
}
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney)
if err != nil {
return err
}
}
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
if err != nil {
return err
}
var estimatedTotalFee *moneyv1.Money
if gasCurrency != "" && !totalFee.IsNegative() {
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
}
var topUpMoney *moneyv1.Money
var topUpFee *moneyv1.Money
topUpPositive := false
if estimatedTotalFee != nil {
computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
})
if err != nil {
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if computeResp != nil {
topUpMoney = computeResp.GetTopupAmount()
}
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
amountDec, err := decimalFromMoney(topUpMoney)
if err != nil {
return err
}
topUpPositive = amountDec.IsPositive()
}
if topUpMoney != nil && topUpPositive {
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
if err != nil {
return err
}
}
}
plan := ensureExecutionPlan(payment)
var gasStep *model.ExecutionStep
if topUpMoney != nil && topUpPositive {
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
gasStep.Description = "Top up native gas from fee wallet"
gasStep.Amount = cloneMoney(topUpMoney)
gasStep.NetworkFee = cloneMoney(topUpFee)
gasStep.SourceWalletRef = feeWalletRef
gasStep.DestinationRef = sourceWalletRef
}
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
fundStep.Description = "Transfer payout amount to card funding wallet"
fundStep.Amount = cloneMoney(amount)
fundStep.NetworkFee = cloneMoney(fundingFee)
fundStep.SourceWalletRef = sourceWalletRef
fundStep.DestinationRef = fundingAddress
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
cardStep.Description = "Submit card payout"
cardStep.Amount = cloneMoney(payoutAmount)
if card := intent.Destination.Card; card != nil {
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
cardStep.DestinationRef = masked
}
}
if feeRequired {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.NetworkFee = cloneMoney(feeTransferFee)
step.SourceWalletRef = sourceWalletRef
step.DestinationRef = feeWalletRef
}
updateExecutionPlanTotalNetworkFee(plan)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if topUpMoney != nil && topUpPositive {
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: feeWalletRef,
TargetWalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
})
if gasErr != nil {
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
return gasErr
}
if gasStep != nil {
actual := (*moneyv1.Money)(nil)
if ensureResp != nil {
actual = ensureResp.GetTopupAmount()
if transfer := ensureResp.GetTransfer(); transfer != nil {
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
}
}
actualPositive := false
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
actualDec, err := decimalFromMoney(actual)
if err != nil {
return err
}
actualPositive = actualDec.IsPositive()
}
if actual != nil && actualPositive {
gasStep.Amount = cloneMoney(actual)
if strings.TrimSpace(actual.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = cloneMoney(topUpFee)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
}
}
if gasStep != nil {
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
}
updateExecutionPlanTotalNetworkFee(plan)
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
@@ -84,42 +282,10 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
fundStep.TransferRef = exec.ChainTransferRef
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
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
return nil
}
@@ -133,9 +299,9 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: amount is required")
amount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
amtDec, err := decimalFromMoney(amount)
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")
}
recordCardPayoutState(payment, state)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
if exec.CardPayoutRef == "" {
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
}
@@ -250,3 +495,147 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat
// 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

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

View File

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

View File

@@ -56,10 +56,11 @@ func (m mntxDependency) available() bool {
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 {
FundingAddress string
FeeAddress string
FeeWalletRef string
}
// WithFeeEngine wires the fee engine client.

View File

@@ -57,7 +57,7 @@ func TestMinQuoteExpiry(t *testing.T) {
later := now.Add(10 * 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 {
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)
}
if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok {
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
t.Fatal("expected min expiry to be unset")
}
}

View File

@@ -158,6 +158,24 @@ type ExecutionRefs struct {
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.
type Payment struct {
storable.Base `bson:",inline" json:",inline"`
@@ -171,6 +189,7 @@ type Payment struct {
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,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"`
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.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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
)
@@ -93,6 +93,6 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // 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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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 "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto";
import "common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets.
enum ChainNetwork {
CHAIN_NETWORK_UNSPECIFIED = 0;
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
CHAIN_NETWORK_ARBITRUM_ONE = 2;
CHAIN_NETWORK_OTHER_EVM = 3;
CHAIN_NETWORK_TRON_MAINNET = 4;
CHAIN_NETWORK_TRON_NILE = 5;
}
enum ManagedWalletStatus {
@@ -57,6 +59,7 @@ message ManagedWallet {
map<string, string> metadata = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
}
message CreateManagedWalletRequest {
@@ -65,6 +68,7 @@ message CreateManagedWalletRequest {
string owner_ref = 3;
Asset asset = 4;
map<string, string> metadata = 5;
common.describable.v1.Describable describable = 6;
}
message CreateManagedWalletResponse {
@@ -96,6 +100,7 @@ message WalletBalance {
common.money.v1.Money pending_inbound = 2;
common.money.v1.Money pending_outbound = 3;
google.protobuf.Timestamp calculated_at = 4;
common.money.v1.Money native_available = 5;
}
message GetWalletBalanceRequest {
@@ -184,6 +189,32 @@ message EstimateTransferFeeResponse {
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 {
string deposit_ref = 1;
string wallet_ref = 2;
@@ -213,4 +244,6 @@ service ChainGatewayService {
rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse);
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;
}
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.
message CardPayout {
string payout_ref = 1;
@@ -166,6 +182,7 @@ message Payment {
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
CardPayout card_payout = 12;
ExecutionPlan execution_plan = 13;
}
message QuotePaymentRequest {

View File

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

View File

@@ -14,7 +14,7 @@ require (
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/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/cors v1.2.2
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/sys v0.39.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/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
)

View File

@@ -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/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
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 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package sresponse
import (
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
@@ -26,6 +27,8 @@ type wallet struct {
DepositAddress string `json:"depositAddress"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
@@ -76,10 +79,31 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
token := ""
contract := ""
if asset != nil {
chain = asset.GetChain().String()
chain = chainNetworkValue(asset.GetChain())
token = asset.GetTokenSymbol()
contract = asset.GetContractAddress()
}
name := ""
if d := w.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(w.GetMetadata()["name"])
}
if name == "" {
name = w.GetWalletRef()
}
var description *string
if d := w.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
description = &trimmed
}
}
return wallet{
WalletRef: w.GetWalletRef(),
OrganizationRef: w.GetOrganizationRef(),
@@ -92,6 +116,8 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
DepositAddress: w.GetDepositAddress(),
Status: w.GetStatus().String(),
Metadata: w.GetMetadata(),
Name: name,
Description: description,
CreatedAt: tsToString(w.GetCreatedAt()),
UpdatedAt: tsToString(w.GetUpdatedAt()),
}
@@ -115,3 +141,15 @@ func tsToString(ts *timestamppb.Timestamp) string {
}
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))
return response.Internal(pr.logger, pr.service, err)
}
pr.logger.Info("Login confirmation code issued",
zap.String("destination", pr.maskEmail(account.Login)),
zap.String("account", account.Login))
pr.logger.Info("Login confirmation code issued", zap.String("destination", pr.maskEmail(account.Login)))
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
}

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ services:
NATS_PORT: ${NATS_PORT}
NATS_USER: ${NATS_USER}
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_ADDRESS: ${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}
VAULT_TOKEN_FILE: /run/vault/token

View File

@@ -18,7 +18,7 @@ SERVICE_NAMES="${CHAIN_GATEWAY_SERVICE_NAME}"
REQUIRED_SECRETS=(
CHAIN_GATEWAY_MONGO_USER
CHAIN_GATEWAY_MONGO_PASSWORD
CHAIN_GATEWAY_ARBITRUM_RPC_URL
CHAIN_GATEWAY_RPC_URL
CHAIN_GATEWAY_SERVICE_WALLET_KEY
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
CHAIN_GATEWAY_VAULT_ROLE_ID
@@ -46,7 +46,7 @@ b64enc() {
CHAIN_GATEWAY_MONGO_USER_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_USER}")"
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_ADDRESS_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}")"
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" \
CHAIN_GATEWAY_MONGO_USER_B64="$CHAIN_GATEWAY_MONGO_USER_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_ADDRESS_B64="$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_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_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_ADDRESS="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_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")"
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_VAULT_ROLE_ID CHAIN_GATEWAY_VAULT_SECRET_ID
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_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_ADDRESS="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" address || true)"

View File

@@ -11,7 +11,9 @@ class ChangePassword {
@JsonKey(name: 'new')
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);
Map<String, dynamic> toJson() => _$ChangePasswordToJson(this);

View File

@@ -0,0 +1,25 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'quotes.g.dart';
@JsonSerializable()
class QuotePaymentsRequest extends PaymentBaseRequest {
final List<PaymentIntentDTO> intents;
@JsonKey(defaultValue: false)
final bool previewOnly;
const QuotePaymentsRequest({
required super.idempotencyKey,
super.metadata,
required this.intents,
this.previewOnly = false,
});
factory QuotePaymentsRequest.fromJson(Map<String, dynamic> json) => _$QuotePaymentsRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$QuotePaymentsRequestToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'username.g.dart';
@JsonSerializable()
class ResetUserNameRequest {
final String userName;
const ResetUserNameRequest({
required this.userName,
});
factory ResetUserNameRequest.fromJson(Map<String, dynamic> json) => _$ResetUserNameRequestFromJson(json);
Map<String, dynamic> toJson() => _$ResetUserNameRequestToJson(this);
static ResetUserNameRequest build({
required String userName,
}) => ResetUserNameRequest(userName: userName);
}

View File

@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
part 'confirmation.g.dart';
@JsonSerializable()
class ConfirmationResponse {
@JsonKey(name: 'ttl_seconds', defaultValue: 0)
final int ttlSeconds;
@JsonKey(name: 'cooldown_seconds', defaultValue: 0)
final int cooldownSeconds;
@JsonKey(defaultValue: '')
final String destination;
const ConfirmationResponse({
required this.ttlSeconds,
required this.cooldownSeconds,
required this.destination,
});
Duration get cooldownDuration => Duration(seconds: cooldownSeconds);
Duration get ttlDuration => Duration(seconds: ttlSeconds);
factory ConfirmationResponse.fromJson(Map<String, dynamic> json) => _$ConfirmationResponseFromJson(json);
Map<String, dynamic> toJson() => _$ConfirmationResponseToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/quotes.dart';
part 'quotes.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentQuotesResponse extends BaseAuthorizedResponse {
final PaymentQuotesDTO quote;
const PaymentQuotesResponse({required super.accessToken, required this.quote});
factory PaymentQuotesResponse.fromJson(Map<String, dynamic> json) => _$PaymentQuotesResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentQuotesResponseToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'quote_aggregate.g.dart';
@JsonSerializable()
class PaymentQuoteAggregateDTO {
final List<MoneyDTO>? debitAmounts;
final List<MoneyDTO>? expectedSettlementAmounts;
final List<MoneyDTO>? expectedFeeTotals;
final List<MoneyDTO>? networkFeeTotals;
const PaymentQuoteAggregateDTO({
this.debitAmounts,
this.expectedSettlementAmounts,
this.expectedFeeTotals,
this.networkFeeTotals,
});
factory PaymentQuoteAggregateDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuoteAggregateDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentQuoteAggregateDTOToJson(this);
}

View File

@@ -0,0 +1,23 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/quote_aggregate.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'quotes.g.dart';
@JsonSerializable()
class PaymentQuotesDTO {
final String quoteRef;
final PaymentQuoteAggregateDTO? aggregate;
final List<PaymentQuoteDTO>? quotes;
const PaymentQuotesDTO({
required this.quoteRef,
this.aggregate,
this.quotes,
});
factory PaymentQuotesDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuotesDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentQuotesDTOToJson(this);
}

View File

@@ -13,6 +13,8 @@ class WalletDTO {
final WalletAssetDTO asset;
final String depositAddress;
final String status;
final String name;
final String? description;
final Map<String, String>? metadata;
final String? createdAt;
final String? updatedAt;
@@ -24,6 +26,8 @@ class WalletDTO {
required this.asset,
required this.depositAddress,
required this.status,
required this.name,
this.description,
this.metadata,
this.createdAt,
this.updatedAt,

View File

@@ -83,17 +83,21 @@ String fxSideToValue(FxSide side) {
}
ChainNetwork chainNetworkFromValue(String? value) {
switch (value) {
final raw = value ?? '';
final normalized = _normalizeChainNetwork(raw);
switch (normalized) {
case 'ethereum_mainnet':
return ChainNetwork.ethereumMainnet;
case 'arbitrum_one':
return ChainNetwork.arbitrumOne;
case 'other_evm':
return ChainNetwork.otherEvm;
case 'tron_mainnet':
return ChainNetwork.tronMainnet;
case 'tron_nile':
return ChainNetwork.tronNile;
case 'unspecified':
return ChainNetwork.unspecified;
default:
throw ArgumentError('Unknown ChainNetwork value: $value');
throw ArgumentError('Unknown ChainNetwork value: $raw');
}
}
@@ -103,13 +107,28 @@ String chainNetworkToValue(ChainNetwork chain) {
return 'ethereum_mainnet';
case ChainNetwork.arbitrumOne:
return 'arbitrum_one';
case ChainNetwork.otherEvm:
return 'other_evm';
case ChainNetwork.tronMainnet:
return 'tron_mainnet';
case ChainNetwork.tronNile:
return 'tron_nile';
case ChainNetwork.unspecified:
return 'unspecified';
}
}
String _normalizeChainNetwork(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return 'unspecified';
}
final lower = trimmed.toLowerCase();
const prefix = 'chain_network_';
if (lower.startsWith(prefix)) {
return lower.substring(prefix.length);
}
return lower;
}
InsufficientNetPolicy insufficientNetPolicyFromValue(String? value) {
switch (value) {
case 'block_posting':

View File

@@ -0,0 +1,22 @@
import 'package:pshared/data/dto/payment/quote_aggregate.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/quote_aggregate.dart';
extension PaymentQuoteAggregateDTOMapper on PaymentQuoteAggregateDTO {
PaymentQuoteAggregate toDomain() => PaymentQuoteAggregate(
debitAmounts: debitAmounts?.map((amount) => amount.toDomain()).toList(),
expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDomain()).toList(),
expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDomain()).toList(),
networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDomain()).toList(),
);
}
extension PaymentQuoteAggregateMapper on PaymentQuoteAggregate {
PaymentQuoteAggregateDTO toDTO() => PaymentQuoteAggregateDTO(
debitAmounts: debitAmounts?.map((amount) => amount.toDTO()).toList(),
expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDTO()).toList(),
expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDTO()).toList(),
networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDTO()).toList(),
);
}

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