Compare commits
44 Commits
SEND010
...
68b82cbca2
| Author | SHA1 | Date | |
|---|---|---|---|
| 68b82cbca2 | |||
|
|
9e6d530385 | ||
| 5836292adb | |||
| 0c6229331f | |||
| 8cb6a64f2b | |||
|
|
4453dab366 | ||
|
|
512f25f74f | ||
|
|
43020f3eb6 | ||
| 964e90767d | |||
|
|
03cd2f4784 | ||
| 2d735aa7f5 | |||
|
|
342dd5328f | ||
| 915ed66b08 | |||
|
|
fe73b3078a | ||
| 76204822e7 | |||
|
|
77c205f9b2 | ||
| 6a29dc8907 | |||
|
|
8f1f279792 | ||
| 1f0b54d590 | |||
|
|
cefb9706f9 | ||
| 79b7899658 | |||
|
|
c941319c4e | ||
| e6626600cc | |||
|
|
e74c06e87a | ||
| c3647bfc46 | |||
|
|
3ff81038a9 | ||
| d6d9d47e67 | |||
|
|
034eb943e2 | ||
| 93bd0bf002 | |||
|
|
946bfa217c | ||
| 318255405b | |||
|
|
19d4ee1d33 | ||
| bc6a56c129 | |||
|
|
ec54579921 | ||
| 1ed76f7243 | |||
|
|
6527d183ec | ||
| 41b0dec460 | |||
|
|
d26ba84094 | ||
|
|
4073c8819c | ||
|
|
47ada0691c | ||
| 97c67670e5 | |||
|
|
dfad7fb335 | ||
| 41abf723e6 | |||
|
|
2d6586430f |
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +49,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -50,5 +50,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ messaging:
|
|||||||
reconnect_wait: 5
|
reconnect_wait: 5
|
||||||
|
|
||||||
chains:
|
chains:
|
||||||
- name: arbitrum_one
|
- name: tron_mainnet
|
||||||
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
chain_id: 728126428 # 0x2b6653dc
|
||||||
|
native_token: TRX
|
||||||
|
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
||||||
tokens:
|
tokens:
|
||||||
- symbol: USDC
|
|
||||||
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
|
|
||||||
- symbol: USDT
|
- symbol: USDT
|
||||||
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
|
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
- symbol: USDC
|
||||||
|
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
|
||||||
|
|
||||||
service_wallet:
|
service_wallet:
|
||||||
chain: arbitrum_one
|
chain: tron_mainnet
|
||||||
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
||||||
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
||||||
|
|
||||||
@@ -58,3 +60,4 @@ key_management:
|
|||||||
|
|
||||||
cache:
|
cache:
|
||||||
wallet_balance_ttl_seconds: 120
|
wallet_balance_ttl_seconds: 120
|
||||||
|
rpc_request_timeout_seconds: 15
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
@@ -86,5 +86,5 @@ require (
|
|||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
|||||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 h1:wCr/SrKzMrtW9wG85ApPfncRr7ajzkRevhsWnCkl2sE=
|
||||||
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-20251223223124-03e3cef63e04/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -362,10 +362,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package serverimp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,8 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
||||||
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
|
||||||
@@ -30,6 +33,8 @@ type Imp struct {
|
|||||||
|
|
||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
|
||||||
|
rpcClients *rpcclient.Clients
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -84,6 +89,9 @@ func (i *Imp) Shutdown() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
|
if i.rpcClients != nil {
|
||||||
|
i.rpcClients.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
@@ -98,20 +106,34 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cl := i.logger.Named("config")
|
cl := i.logger.Named("config")
|
||||||
networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("invalid chain network configuration", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.rpcClients = rpcClients
|
||||||
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
|
||||||
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
|
|
||||||
opts := []gatewayservice.Option{
|
opts := []gatewayservice.Option{
|
||||||
gatewayservice.WithNetworks(networkConfigs),
|
gatewayservice.WithNetworks(networkConfigs),
|
||||||
gatewayservice.WithServiceWallet(walletConfig),
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
gatewayservice.WithKeyManager(keyManager),
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
gatewayservice.WithTransferExecutor(executor),
|
gatewayservice.WithRPCClients(rpcClients),
|
||||||
|
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||||
gatewayservice.WithSettings(cfg.Settings),
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||||
@@ -157,7 +179,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
|
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
|
||||||
result := make([]gatewayshared.Network, 0, len(chains))
|
result := make([]gatewayshared.Network, 0, len(chains))
|
||||||
for _, chain := range chains {
|
for _, chain := range chains {
|
||||||
if strings.TrimSpace(chain.Name) == "" {
|
if strings.TrimSpace(chain.Name) == "" {
|
||||||
@@ -166,7 +188,8 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
|||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
|
||||||
}
|
}
|
||||||
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
|
||||||
for _, token := range chain.Tokens {
|
for _, token := range chain.Tokens {
|
||||||
@@ -202,7 +225,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewa
|
|||||||
TokenConfigs: contracts,
|
TokenConfigs: contracts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package transfer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
@@ -11,9 +14,11 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,22 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
|||||||
deps.Logger.Warn("destination external address missing")
|
deps.Logger.Warn("destination external address missing")
|
||||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||||
}
|
}
|
||||||
|
if deps.Drivers == nil {
|
||||||
|
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
|
||||||
|
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||||
|
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||||
|
}
|
||||||
|
normalized, err := chainDriver.NormalizeAddress(external)
|
||||||
|
if err != nil {
|
||||||
|
deps.Logger.Warn("invalid external address", zap.Error(err))
|
||||||
|
return model.TransferDestination{}, err
|
||||||
|
}
|
||||||
return model.TransferDestination{
|
return model.TransferDestination{
|
||||||
ExternalAddress: strings.ToLower(external),
|
ExternalAddress: normalized,
|
||||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDestination) (string, error) {
|
func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
wallet, err := deps.Storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -17,10 +18,10 @@ func destinationAddress(ctx context.Context, deps Deps, dest model.TransferDesti
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,12 @@ package transfer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -63,11 +53,20 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
}
|
}
|
||||||
|
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
}
|
}
|
||||||
|
if c.deps.Drivers == nil {
|
||||||
|
c.deps.Logger.Warn("chain drivers missing", zap.String("network", networkKey))
|
||||||
|
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
|
}
|
||||||
|
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
}
|
||||||
|
|
||||||
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,13 +78,18 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := destinationAddress(ctx, c.deps, dest)
|
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
||||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
|
driverDeps := driver.Deps{
|
||||||
|
Logger: c.deps.Logger,
|
||||||
|
Registry: c.deps.Networks,
|
||||||
|
RPCTimeout: c.deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
||||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
@@ -97,152 +101,3 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
|
|||||||
}
|
}
|
||||||
return gsresponse.Success(resp)
|
return gsresponse.Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
|
||||||
if rpcURL == "" {
|
|
||||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(wallet.ContractAddress) == "" {
|
|
||||||
return nil, merrors.NotImplemented("native token transfers not supported")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.ContractAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(wallet.DepositAddress) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
|
||||||
}
|
|
||||||
if !common.IsHexAddress(destination) {
|
|
||||||
return nil, merrors.InvalidArgument("invalid destination address")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to connect to rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
|
||||||
toAddr := common.HexToAddress(destination)
|
|
||||||
fromAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := erc20Decimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("failed to read token decimals", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
input, err := tokenABI.Pack("transfer", toAddr, amountBase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
callMsg := ethereum.CallMsg{
|
|
||||||
From: fromAddr,
|
|
||||||
To: &tokenAddr,
|
|
||||||
GasPrice: gasPrice,
|
|
||||||
Data: input,
|
|
||||||
}
|
|
||||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
|
||||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
|
||||||
|
|
||||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
|
||||||
if currency == "" {
|
|
||||||
currency = strings.ToUpper(network.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Currency: currency,
|
|
||||||
Amount: feeDec.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
callData, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{
|
|
||||||
To: &token,
|
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", output)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals: " + err.Error())
|
|
||||||
}
|
|
||||||
if len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("decimals call returned no data")
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, merrors.Internal("decimals call returned unexpected type")
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
|
||||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error())
|
|
||||||
}
|
|
||||||
if value.IsNegative() {
|
|
||||||
return nil, merrors.InvalidArgument("amount must be positive")
|
|
||||||
}
|
|
||||||
multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
|
|
||||||
scaled := value.Mul(multiplier)
|
|
||||||
if !scaled.Equal(scaled.Truncate(0)) {
|
|
||||||
return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision")
|
|
||||||
}
|
|
||||||
return scaled.BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20TransferABI = `
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [],
|
|
||||||
"name": "decimals",
|
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": false,
|
|
||||||
"inputs": [
|
|
||||||
{ "name": "_to", "type": "address" },
|
|
||||||
{ "name": "_value", "type": "uint256" }
|
|
||||||
],
|
|
||||||
"name": "transfer",
|
|
||||||
"outputs": [{ "name": "", "type": "bool" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "nonpayable",
|
|
||||||
"type": "function"
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
|||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||||
}
|
}
|
||||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||||
networkCfg, ok := c.deps.Networks[networkKey]
|
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -59,11 +60,20 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
networkCfg, ok := c.deps.Networks[chainKey]
|
networkCfg, ok := c.deps.Networks.Network(chainKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
||||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
}
|
}
|
||||||
|
if c.deps.Drivers == nil {
|
||||||
|
c.deps.Logger.Warn("chain drivers missing", zap.String("chain", chainKey))
|
||||||
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||||
|
}
|
||||||
|
chainDriver, err := c.deps.Drivers.Driver(chainKey)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||||
|
}
|
||||||
|
|
||||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
if tokenSymbol == "" {
|
if tokenSymbol == "" {
|
||||||
@@ -94,8 +104,37 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
c.deps.Logger.Warn("key manager returned empty address")
|
c.deps.Logger.Warn("key manager returned empty address")
|
||||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||||
}
|
}
|
||||||
|
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
|
||||||
|
if err != nil {
|
||||||
|
c.deps.Logger.Warn("invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
|
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := shared.CloneMetadata(req.GetMetadata())
|
||||||
|
desc := req.GetDescribable()
|
||||||
|
name := strings.TrimSpace(desc.GetName())
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(metadata["name"])
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if desc != nil && desc.Description != nil {
|
||||||
|
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description == nil {
|
||||||
|
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = walletRef
|
||||||
|
}
|
||||||
|
|
||||||
wallet := &model.ManagedWallet{
|
wallet := &model.ManagedWallet{
|
||||||
|
Describable: pkgmodel.Describable{
|
||||||
|
Name: name,
|
||||||
|
},
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
OrganizationRef: organizationRef,
|
OrganizationRef: organizationRef,
|
||||||
@@ -103,10 +142,13 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
Network: chainKey,
|
Network: chainKey,
|
||||||
TokenSymbol: tokenSymbol,
|
TokenSymbol: tokenSymbol,
|
||||||
ContractAddress: contractAddress,
|
ContractAddress: contractAddress,
|
||||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
DepositAddress: depositAddress,
|
||||||
KeyReference: keyInfo.KeyID,
|
KeyReference: keyInfo.KeyID,
|
||||||
Status: model.ManagedWalletStatusActive,
|
Status: model.ManagedWalletStatusActive,
|
||||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
if description != nil {
|
||||||
|
wallet.Describable.Description = description
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -13,17 +14,20 @@ import (
|
|||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Logger mlogger.Logger
|
Logger mlogger.Logger
|
||||||
Networks map[string]shared.Network
|
Drivers *drivers.Registry
|
||||||
|
Networks *rpcclient.Registry
|
||||||
KeyManager keymanager.Manager
|
KeyManager keymanager.Manager
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
Clock clockpkg.Clock
|
Clock clockpkg.Clock
|
||||||
BalanceCacheTTL time.Duration
|
BalanceCacheTTL time.Duration
|
||||||
|
RPCTimeout time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Deps) WithLogger(name string) Deps {
|
func (d Deps) WithLogger(name string) Deps {
|
||||||
if d.Logger != nil {
|
if d.Logger == nil {
|
||||||
d.Logger = d.Logger.Named(name)
|
panic("wallet deps: logger is required")
|
||||||
}
|
}
|
||||||
|
d.Logger = d.Logger.Named(name)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,123 +2,53 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"math/big"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||||
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
|
logger := deps.Logger
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
if wallet == nil {
|
||||||
if rpcURL == "" {
|
return nil, merrors.InvalidArgument("wallet is required")
|
||||||
return nil, merrors.Internal("network rpc url is not configured")
|
|
||||||
}
|
}
|
||||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
if deps.Networks == nil {
|
||||||
if contract == "" || !common.IsHexAddress(contract) {
|
return nil, merrors.Internal("rpc clients not initialised")
|
||||||
return nil, merrors.InvalidArgument("invalid contract address")
|
|
||||||
}
|
}
|
||||||
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
|
if deps.Drivers == nil {
|
||||||
return nil, merrors.InvalidArgument("invalid wallet address")
|
return nil, merrors.Internal("chain drivers not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
||||||
if err != nil {
|
network, ok := deps.Networks.Network(networkKey)
|
||||||
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
|
|
||||||
}
|
|
||||||
tokenAddr := common.HexToAddress(contract)
|
|
||||||
walletAddr := common.HexToAddress(wallet.DepositAddress)
|
|
||||||
|
|
||||||
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
|
|
||||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) {
|
|
||||||
data, err := tokenABI.Pack("decimals")
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("failed to encode decimals call: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.Internal("decimals call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("decimals", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return 0, merrors.Internal("failed to unpack decimals")
|
|
||||||
}
|
|
||||||
if val, ok := values[0].(uint8); ok {
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
return 0, merrors.Internal("decimals returned unexpected type")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) {
|
|
||||||
data, err := tokenABI.Pack("balanceOf", wallet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("failed to encode balanceOf: " + err.Error())
|
|
||||||
}
|
|
||||||
msg := ethereum.CallMsg{To: &token, Data: data}
|
|
||||||
out, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.Internal("balanceOf call failed: " + err.Error())
|
|
||||||
}
|
|
||||||
values, err := tokenABI.Unpack("balanceOf", out)
|
|
||||||
if err != nil || len(values) == 0 {
|
|
||||||
return nil, merrors.Internal("failed to unpack balanceOf")
|
|
||||||
}
|
|
||||||
raw, ok := values[0].(*big.Int)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, merrors.Internal("balanceOf returned unexpected type")
|
logger.Warn("Requested network is not configured",
|
||||||
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
|
zap.String("network", networkKey),
|
||||||
|
)
|
||||||
|
return nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||||
}
|
}
|
||||||
return decimal.NewFromBigInt(raw, 0).BigInt(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const erc20ABIJSON = `
|
chainDriver, err := deps.Drivers.Driver(networkKey)
|
||||||
[
|
if err != nil {
|
||||||
{
|
logger.Warn("Chain driver not configured",
|
||||||
"constant": true,
|
zap.String("wallet_ref", wallet.WalletRef),
|
||||||
"inputs": [],
|
zap.String("network", networkKey),
|
||||||
"name": "decimals",
|
zap.Error(err),
|
||||||
"outputs": [{ "name": "", "type": "uint8" }],
|
)
|
||||||
"payable": false,
|
return nil, merrors.InvalidArgument("unsupported chain")
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"constant": true,
|
|
||||||
"inputs": [{ "name": "_owner", "type": "address" }],
|
|
||||||
"name": "balanceOf",
|
|
||||||
"outputs": [{ "name": "balance", "type": "uint256" }],
|
|
||||||
"payable": false,
|
|
||||||
"stateMutability": "view",
|
|
||||||
"type": "function"
|
|
||||||
}
|
}
|
||||||
]`
|
|
||||||
|
driverDeps := driver.Deps{
|
||||||
|
Logger: deps.Logger,
|
||||||
|
Registry: deps.Networks,
|
||||||
|
KeyManager: deps.KeyManager,
|
||||||
|
RPCTimeout: deps.RPCTimeout,
|
||||||
|
}
|
||||||
|
return chainDriver.Balance(ctx, driverDeps, network, wallet)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package wallet
|
package wallet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
TokenSymbol: wallet.TokenSymbol,
|
TokenSymbol: wallet.TokenSymbol,
|
||||||
ContractAddress: wallet.ContractAddress,
|
ContractAddress: wallet.ContractAddress,
|
||||||
}
|
}
|
||||||
|
name := strings.TrimSpace(wallet.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(wallet.Metadata["name"])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = wallet.WalletRef
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
switch {
|
||||||
|
case wallet.Description != nil:
|
||||||
|
description = strings.TrimSpace(*wallet.Description)
|
||||||
|
default:
|
||||||
|
description = strings.TrimSpace(wallet.Metadata["description"])
|
||||||
|
}
|
||||||
|
desc := &describablev1.Describable{Name: name}
|
||||||
|
if description != "" {
|
||||||
|
desc.Description = &description
|
||||||
|
}
|
||||||
|
|
||||||
return &chainv1.ManagedWallet{
|
return &chainv1.ManagedWallet{
|
||||||
WalletRef: wallet.WalletRef,
|
WalletRef: wallet.WalletRef,
|
||||||
OrganizationRef: wallet.OrganizationRef,
|
OrganizationRef: wallet.OrganizationRef,
|
||||||
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
Metadata: shared.CloneMetadata(wallet.Metadata),
|
Metadata: shared.CloneMetadata(wallet.Metadata),
|
||||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||||
|
Describable: desc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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) 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)
|
||||||
33
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
33
api/gateway/chain/internal/service/gateway/driver/driver.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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) 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)
|
||||||
554
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
554
api/gateway/chain/internal/service/gateway/driver/evm/evm.go
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" || !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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 _, 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()
|
||||||
|
|
||||||
|
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
||||||
|
toAddr := common.HexToAddress(destination)
|
||||||
|
fromAddr := common.HexToAddress(fromAddress)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.ToUpper(network.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
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)
|
||||||
|
|
||||||
|
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||||
|
logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||||
|
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||||
|
logger.Warn("Invalid token contract address",
|
||||||
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
|
zap.String("contract", transfer.ContractAddress),
|
||||||
|
)
|
||||||
|
return "", executorInvalid("invalid token contract address " + transfer.ContractAddress)
|
||||||
|
}
|
||||||
|
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||||
|
|
||||||
|
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", transfer.ContractAddress),
|
||||||
|
)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
191
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
191
api/gateway/chain/internal/service/gateway/driver/tron/driver.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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) 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)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
"github.com/ethereum/go-ethereum"
|
||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/rpc"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ type TransferExecutor interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
|
||||||
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
|
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor {
|
||||||
return &onChainExecutor{
|
return &onChainExecutor{
|
||||||
logger: logger.Named("executor"),
|
logger: logger.Named("executor"),
|
||||||
keyManager: keyManager,
|
keyManager: keyManager,
|
||||||
clients: map[string]*ethclient.Client{},
|
clients: clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,34 +42,33 @@ type onChainExecutor struct {
|
|||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
|
|
||||||
mu sync.Mutex
|
clients *rpcclient.Clients
|
||||||
clients map[string]*ethclient.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||||
if o.keyManager == nil {
|
if o.keyManager == nil {
|
||||||
o.logger.Error("key manager not configured")
|
o.logger.Warn("key manager not configured")
|
||||||
return "", executorInternal("key manager is not configured", nil)
|
return "", executorInternal("key manager is not configured", nil)
|
||||||
}
|
}
|
||||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||||
if rpcURL == "" {
|
if rpcURL == "" {
|
||||||
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
|
o.logger.Warn("network rpc url missing", zap.String("network", network.Name))
|
||||||
return "", executorInvalid("network rpc url is not configured")
|
return "", executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
if source == nil || transfer == nil {
|
if source == nil || transfer == nil {
|
||||||
o.logger.Error("transfer context missing")
|
o.logger.Warn("transfer context missing")
|
||||||
return "", executorInvalid("transfer context missing")
|
return "", executorInvalid("transfer context missing")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(source.KeyReference) == "" {
|
if strings.TrimSpace(source.KeyReference) == "" {
|
||||||
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||||
return "", executorInvalid("source wallet missing key reference")
|
return "", executorInvalid("source wallet missing key reference")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(source.DepositAddress) == "" {
|
if strings.TrimSpace(source.DepositAddress) == "" {
|
||||||
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||||
return "", executorInvalid("source wallet missing deposit address")
|
return "", executorInvalid("source wallet missing deposit address")
|
||||||
}
|
}
|
||||||
if !common.IsHexAddress(destinationAddress) {
|
if !common.IsHexAddress(destinationAddress) {
|
||||||
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||||
return "", executorInvalid("invalid destination address " + destinationAddress)
|
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +79,15 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
zap.String("destination", strings.ToLower(destinationAddress)),
|
zap.String("destination", strings.ToLower(destinationAddress)),
|
||||||
)
|
)
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
|
if err != nil {
|
||||||
|
o.logger.Warn("failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to initialise rpc client",
|
o.logger.Warn("failed to initialise rpc client",
|
||||||
zap.String("network", network.Name),
|
zap.String("network", network.Name),
|
||||||
zap.String("rpc_url", rpcURL),
|
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
@@ -98,10 +101,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to fetch nonce",
|
o.logger.Warn("failed to fetch nonce", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to fetch nonce", err)
|
return "", executorInternal("failed to fetch nonce", err)
|
||||||
}
|
}
|
||||||
@@ -135,12 +137,11 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||||
|
|
||||||
decimals, err := erc20Decimals(ctx, client, tokenAddress)
|
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to read token decimals",
|
o.logger.Warn("failed to read token decimals", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("contract", transfer.ContractAddress),
|
zap.String("contract", transfer.ContractAddress),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -152,10 +153,9 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
}
|
}
|
||||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to convert amount to base units",
|
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("amount", amount.Amount),
|
zap.String("amount", amount.Amount),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -188,18 +188,16 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
|
|
||||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.logger.Warn("failed to sign transaction",
|
o.logger.Warn("failed to sign transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.String("wallet_ref", source.WalletRef),
|
zap.String("wallet_ref", source.WalletRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||||
o.logger.Warn("failed to send transaction",
|
o.logger.Warn("failed to send transaction", zap.Error(err),
|
||||||
zap.String("transfer_ref", transfer.TransferRef),
|
zap.String("transfer_ref", transfer.TransferRef),
|
||||||
zap.Error(err),
|
|
||||||
)
|
)
|
||||||
return "", executorInternal("failed to send transaction", err)
|
return "", executorInternal("failed to send transaction", err)
|
||||||
}
|
}
|
||||||
@@ -214,30 +212,6 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
|||||||
return txHash, nil
|
return txHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
|
|
||||||
o.mu.Lock()
|
|
||||||
client, ok := o.clients[rpcURL]
|
|
||||||
o.mu.Unlock()
|
|
||||||
if ok {
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ethclient.DialContext(ctx, rpcURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
o.mu.Lock()
|
|
||||||
defer o.mu.Unlock()
|
|
||||||
if existing, ok := o.clients[rpcURL]; ok {
|
|
||||||
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
|
|
||||||
c.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
o.clients[rpcURL] = c
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||||
if strings.TrimSpace(txHash) == "" {
|
if strings.TrimSpace(txHash) == "" {
|
||||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||||
@@ -249,7 +223,7 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
|||||||
return nil, executorInvalid("network rpc url is not configured")
|
return nil, executorInvalid("network rpc url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := o.getClient(ctx, rpcURL)
|
client, err := o.clients.Client(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -331,31 +305,20 @@ const erc20ABIJSON = `
|
|||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
|
func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) {
|
||||||
callData, err := erc20ABI.Pack("decimals")
|
call := map[string]string{
|
||||||
if err != nil {
|
"to": strings.ToLower(token.Hex()),
|
||||||
return 0, executorInternal("failed to encode decimals call", err)
|
"data": "0x313ce567",
|
||||||
}
|
}
|
||||||
msg := ethereum.CallMsg{
|
var hexResp string
|
||||||
To: &token,
|
if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil {
|
||||||
Data: callData,
|
|
||||||
}
|
|
||||||
output, err := client.CallContract(ctx, msg, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 0, executorInternal("decimals call failed", err)
|
return 0, executorInternal("decimals call failed", err)
|
||||||
}
|
}
|
||||||
values, err := erc20ABI.Unpack("decimals", output)
|
val, err := shared.DecodeHexUint8(hexResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, executorInternal("failed to unpack decimals", err)
|
return 0, executorInternal("decimals decode failed", err)
|
||||||
}
|
}
|
||||||
if len(values) == 0 {
|
return val, nil
|
||||||
return 0, executorInternal("decimals call returned no data", nil)
|
|
||||||
}
|
|
||||||
decimals, ok := values[0].(uint8)
|
|
||||||
if !ok {
|
|
||||||
return 0, executorInternal("decimals call returned unexpected type", nil)
|
|
||||||
}
|
|
||||||
return decimals, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -18,10 +20,10 @@ func WithKeyManager(manager keymanager.Manager) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithTransferExecutor configures the executor responsible for on-chain submissions.
|
// WithRPCClients configures pre-initialised RPC clients.
|
||||||
func WithTransferExecutor(executor TransferExecutor) Option {
|
func WithRPCClients(clients *rpcclient.Clients) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.executor = executor
|
s.rpcClients = clients
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,13 @@ func WithServiceWallet(wallet shared.ServiceWallet) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDriverRegistry configures the chain driver registry.
|
||||||
|
func WithDriverRegistry(registry *drivers.Registry) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.drivers = registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithClock overrides the service clock.
|
// WithClock overrides the service clock.
|
||||||
func WithClock(clk clockpkg.Clock) Option {
|
func WithClock(clk clockpkg.Clock) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
|
|||||||
196
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal file
196
api/gateway/chain/internal/service/gateway/rpcclient/clients.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
@@ -38,11 +40,13 @@ type Service struct {
|
|||||||
|
|
||||||
settings CacheSettings
|
settings CacheSettings
|
||||||
|
|
||||||
networks map[string]shared.Network
|
networks map[string]shared.Network
|
||||||
serviceWallet shared.ServiceWallet
|
serviceWallet shared.ServiceWallet
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
executor TransferExecutor
|
rpcClients *rpcclient.Clients
|
||||||
commands commands.Registry
|
networkRegistry *rpcclient.Registry
|
||||||
|
drivers *drivers.Registry
|
||||||
|
commands commands.Registry
|
||||||
|
|
||||||
chainv1.UnimplementedChainGatewayServiceServer
|
chainv1.UnimplementedChainGatewayServiceServer
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.networks = map[string]shared.Network{}
|
svc.networks = map[string]shared.Network{}
|
||||||
}
|
}
|
||||||
svc.settings = svc.settings.withDefaults()
|
svc.settings = svc.settings.withDefaults()
|
||||||
|
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
|
||||||
|
|
||||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
@@ -131,11 +136,13 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
|||||||
func commandsWalletDeps(s *Service) wallet.Deps {
|
func commandsWalletDeps(s *Service) wallet.Deps {
|
||||||
return wallet.Deps{
|
return wallet.Deps{
|
||||||
Logger: s.logger.Named("command"),
|
Logger: s.logger.Named("command"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
KeyManager: s.keyManager,
|
KeyManager: s.keyManager,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +150,11 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
|||||||
func commandsTransferDeps(s *Service) transfer.Deps {
|
func commandsTransferDeps(s *Service) transfer.Deps {
|
||||||
return transfer.Deps{
|
return transfer.Deps{
|
||||||
Logger: s.logger.Named("transfer_cmd"),
|
Logger: s.logger.Named("transfer_cmd"),
|
||||||
Networks: s.networks,
|
Drivers: s.drivers,
|
||||||
|
Networks: s.networkRegistry,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
Clock: s.clock,
|
Clock: s.clock,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
LaunchExecution: s.launchTransferExecution,
|
LaunchExecution: s.launchTransferExecution,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
@@ -526,18 +527,22 @@ func sanitizeLimit(requested int32, def, max int64) int64 {
|
|||||||
return int64(requested)
|
return int64(requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestService(_ *testing.T) (*Service, *inMemoryRepository) {
|
func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
||||||
repo := newInMemoryRepository()
|
repo := newInMemoryRepository()
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
|
networks := []shared.Network{{
|
||||||
|
Name: "ethereum_mainnet",
|
||||||
|
TokenConfigs: []shared.TokenContract{
|
||||||
|
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks)
|
||||||
|
require.NoError(t, err)
|
||||||
svc := NewService(logger, repo, nil,
|
svc := NewService(logger, repo, nil,
|
||||||
WithKeyManager(&fakeKeyManager{}),
|
WithKeyManager(&fakeKeyManager{}),
|
||||||
WithNetworks([]shared.Network{{
|
WithNetworks(networks),
|
||||||
Name: "ethereum_mainnet",
|
|
||||||
TokenConfigs: []shared.TokenContract{
|
|
||||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
|
||||||
},
|
|
||||||
}}),
|
|
||||||
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}),
|
||||||
|
WithDriverRegistry(driverRegistry),
|
||||||
)
|
)
|
||||||
return svc, repo
|
return svc, repo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package gateway
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||||
|
const defaultRPCRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
// CacheSettings holds tunable gateway behaviour.
|
// CacheSettings holds tunable gateway behaviour.
|
||||||
type CacheSettings struct {
|
type CacheSettings struct {
|
||||||
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||||
|
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultSettings() CacheSettings {
|
func defaultSettings() CacheSettings {
|
||||||
return CacheSettings{
|
return CacheSettings{
|
||||||
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||||
|
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ func (s CacheSettings) withDefaults() CacheSettings {
|
|||||||
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||||
}
|
}
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,3 +34,10 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) rpcTimeout() time.Duration {
|
||||||
|
if s.RPCRequestTimeoutSeconds <= 0 {
|
||||||
|
return defaultRPCRequestTimeout
|
||||||
|
}
|
||||||
|
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|||||||
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal file
49
api/gateway/chain/internal/service/gateway/shared/hex.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) {
|
||||||
if s.executor == nil {
|
if s.drivers == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
||||||
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||||
}
|
}
|
||||||
}(transferRef, sourceWalletRef, network)
|
}(transferRef, sourceWalletRef, network)
|
||||||
}
|
}
|
||||||
@@ -44,13 +44,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationAddress, err := s.destinationAddress(ctx, transfer.Destination)
|
driverDeps := s.driverDeps()
|
||||||
|
chainDriver, err := s.driverForNetwork(network.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
txHash, err := s.executor.SubmitTransfer(ctx, transfer, sourceWallet, destinationAddress, network)
|
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||||
return err
|
return err
|
||||||
@@ -62,7 +69,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
|
|
||||||
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
receipt, err := s.executor.AwaitConfirmation(receiptCtx, network, txHash)
|
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||||
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||||
@@ -83,7 +90,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDestination) (string, error) {
|
func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) {
|
||||||
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" {
|
||||||
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
wallet, err := s.storage.Wallets().Get(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,10 +99,26 @@ func (s *Service) destinationAddress(ctx context.Context, dest model.TransferDes
|
|||||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||||
return "", merrors.Internal("destination wallet missing deposit address")
|
return "", merrors.Internal("destination wallet missing deposit address")
|
||||||
}
|
}
|
||||||
return wallet.DepositAddress, nil
|
return chainDriver.NormalizeAddress(wallet.DepositAddress)
|
||||||
}
|
}
|
||||||
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" {
|
||||||
return strings.ToLower(addr), nil
|
return chainDriver.NormalizeAddress(addr)
|
||||||
}
|
}
|
||||||
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
return "", merrors.InvalidArgument("transfer destination address not resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverDeps() driver.Deps {
|
||||||
|
return driver.Deps{
|
||||||
|
Logger: s.logger.Named("driver"),
|
||||||
|
Registry: s.networkRegistry,
|
||||||
|
KeyManager: s.keyManager,
|
||||||
|
RPCTimeout: s.settings.rpcTimeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
|
||||||
|
if s.drivers == nil {
|
||||||
|
return nil, merrors.Internal("chain drivers not configured")
|
||||||
|
}
|
||||||
|
return s.drivers.Driver(network)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,8 @@ const (
|
|||||||
|
|
||||||
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
||||||
type ManagedWallet struct {
|
type ManagedWallet struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
pkgmodel.Describable `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
@@ -77,10 +79,19 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
||||||
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
||||||
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
||||||
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
|
if m.Description != nil {
|
||||||
|
desc := strings.TrimSpace(*m.Description)
|
||||||
|
if desc == "" {
|
||||||
|
m.Description = nil
|
||||||
|
} else {
|
||||||
|
m.Description = &desc
|
||||||
|
}
|
||||||
|
}
|
||||||
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||||
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||||
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||||
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
|
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)
|
||||||
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
m.KeyReference = strings.TrimSpace(m.KeyReference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +99,31 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
func (b *WalletBalance) Normalize() {
|
func (b *WalletBalance) Normalize() {
|
||||||
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
b.WalletRef = strings.TrimSpace(b.WalletRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddress(address string) string {
|
||||||
|
trimmed := strings.TrimSpace(address)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if isHexAddress(trimmed) {
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexAddress(value string) bool {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x")
|
||||||
|
if len(trimmed) != 40 && len(trimmed) != 42 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r >= 'a' && r <= 'f':
|
||||||
|
case r >= 'A' && r <= 'F':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -50,5 +50,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -51,5 +51,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ require (
|
|||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,10 +227,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -62,5 +62,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
@@ -413,74 +411,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
|
|
||||||
switch code {
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
|
|
||||||
return model.PaymentFailureCodeBalance
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
|
|
||||||
return model.PaymentFailureCodeLedger
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
|
|
||||||
return model.PaymentFailureCodeFX
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
|
|
||||||
return model.PaymentFailureCodeChain
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
|
|
||||||
return model.PaymentFailureCodeFees
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
|
|
||||||
return model.PaymentFailureCodePolicy
|
|
||||||
default:
|
|
||||||
return model.PaymentFailureCodeUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
|
|
||||||
if src == nil || dst == nil {
|
|
||||||
return merrors.InvalidArgument("payment payload is required")
|
|
||||||
}
|
|
||||||
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
|
|
||||||
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
|
|
||||||
dst.Intent = intentFromProto(src.GetIntent())
|
|
||||||
dst.State = modelStateFromProto(src.GetState())
|
|
||||||
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
|
|
||||||
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
|
|
||||||
dst.Metadata = cloneMetadata(src.GetMetadata())
|
|
||||||
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
|
|
||||||
dst.Execution = executionFromProto(src.GetExecution())
|
|
||||||
if src.GetCardPayout() != nil {
|
|
||||||
dst.CardPayout = &model.CardPayout{
|
|
||||||
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
|
|
||||||
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
|
|
||||||
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
|
|
||||||
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
|
|
||||||
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
|
|
||||||
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
|
|
||||||
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
|
|
||||||
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
|
|
||||||
if src == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &model.ExecutionRefs{
|
|
||||||
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
|
|
||||||
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
|
|
||||||
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
|
|
||||||
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
|
|
||||||
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
|
|
||||||
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
|
|
||||||
if req == nil {
|
|
||||||
return &paginationv1.CursorPageRequest{}
|
|
||||||
}
|
|
||||||
if req.GetPage() == nil {
|
|
||||||
return &paginationv1.CursorPageRequest{}
|
|
||||||
}
|
|
||||||
return req.GetPage()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -214,20 +214,6 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
|||||||
return decimal.NewFromString(m.GetAmount())
|
return decimal.NewFromString(m.GetAmount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
|
|
||||||
if reference == nil || candidate == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value, err := decimal.NewFromString(candidate.GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
||||||
return &moneyv1.Money{
|
return &moneyv1.Money{
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestMinQuoteExpiry(t *testing.T) {
|
|||||||
later := now.Add(10 * time.Minute)
|
later := now.Add(10 * time.Minute)
|
||||||
earliest := now.Add(5 * time.Minute)
|
earliest := now.Add(5 * time.Minute)
|
||||||
|
|
||||||
min, ok := minQuoteExpiry([]time.Time{later, time.Time{}, earliest})
|
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected min expiry to be set")
|
t.Fatal("expected min expiry to be set")
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestMinQuoteExpiry(t *testing.T) {
|
|||||||
t.Fatalf("expected min expiry %v, got %v", earliest, min)
|
t.Fatalf("expected min expiry %v, got %v", earliest, min)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok {
|
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
|
||||||
t.Fatal("expected min expiry to be unset")
|
t.Fatal("expected min expiry to be unset")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ func Error[T any](logger mlogger.Logger, service mservice.Type, code codes.Code,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fields = append(fields, zap.Error(err))
|
fields = append(fields, zap.Error(err))
|
||||||
}
|
}
|
||||||
logFn := logger.Warn
|
logger.Warn("gRPC request failed", fields...)
|
||||||
switch code {
|
|
||||||
case codes.Internal, codes.DataLoss, codes.Unavailable:
|
|
||||||
logFn = logger.Error
|
|
||||||
}
|
|
||||||
logFn("gRPC request failed", fields...)
|
|
||||||
|
|
||||||
msg := message(err)
|
msg := message(err)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -32,7 +32,7 @@ func TestUnarySuccess(t *testing.T) {
|
|||||||
return Success(resp)
|
return Success(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
unary := Unary[testRequest, testResponse](logger, mservice.Type("test"), handler)
|
unary := Unary(logger, mservice.Type("test"), handler)
|
||||||
resp, err := unary(context.Background(), &testRequest{Value: "hello"})
|
resp, err := unary(context.Background(), &testRequest{Value: "hello"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, resp)
|
require.NotNil(t, resp)
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error)
|
|||||||
{Field: "clientId", Sort: ri.Asc},
|
{Field: "clientId", Sort: ri.Asc},
|
||||||
{Field: "deviceId", Sort: ri.Asc},
|
{Field: "deviceId", Sort: ri.Asc},
|
||||||
},
|
},
|
||||||
Unique: true,
|
Unique: true,
|
||||||
|
Name: "unique_active_session",
|
||||||
|
PartialFilter: repository.Filter(IsRevokedField, false),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
|
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -10,23 +10,29 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
|
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
factory "github.com/tech/sendico/pkg/mlogger/factory"
|
factory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/testcontainers/testcontainers-go"
|
"github.com/testcontainers/testcontainers-go"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
|
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
|
||||||
|
db, _, cleanup := setupTestDBWithMongo(t)
|
||||||
|
return db, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
|
||||||
// mark as helper for better test failure reporting
|
// mark as helper for better test failure reporting
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
|
|||||||
_ = mongoContainer.Terminate(termCtx)
|
_ = mongoContainer.Terminate(termCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, cleanup
|
return db, database, cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
|
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
|
||||||
@@ -332,6 +338,63 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
|
|||||||
_, err = db.GetByCRT(ctx, secondCRT)
|
_, err = db.GetByCRT(ctx, secondCRT)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
|
||||||
|
userID := primitive.NewObjectID()
|
||||||
|
clientID := "web-app"
|
||||||
|
deviceID := "user-laptop"
|
||||||
|
|
||||||
|
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
|
||||||
|
err := db.Create(ctx, firstToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, firstToken.GetID())
|
||||||
|
|
||||||
|
// Global revoke (deviceID empty) — all tokens should be revoked
|
||||||
|
err = db.RevokeAll(ctx, userID, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var revoked model.RefreshToken
|
||||||
|
err = db.Get(ctx, *firstToken.GetID(), &revoked)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, revoked.IsRevoked)
|
||||||
|
|
||||||
|
// Creating a new token for the same account/client/device must succeed and produce an active token
|
||||||
|
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
|
||||||
|
err = db.Create(ctx, reissueToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newCRT := &model.ClientRefreshToken{
|
||||||
|
SessionIdentifier: model.SessionIdentifier{
|
||||||
|
ClientID: clientID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
},
|
||||||
|
RefreshToken: "new_token_after_revocation",
|
||||||
|
}
|
||||||
|
_, err = db.GetByCRT(ctx, newCRT)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Old token must remain unusable
|
||||||
|
oldCRT := &model.ClientRefreshToken{
|
||||||
|
SessionIdentifier: model.SessionIdentifier{
|
||||||
|
ClientID: clientID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
},
|
||||||
|
RefreshToken: "revoked_token_123",
|
||||||
|
}
|
||||||
|
_, err = db.GetByCRT(ctx, oldCRT)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Both records exist: revoked + new active
|
||||||
|
query := repository.Query().
|
||||||
|
Filter(repository.AccountField(), userID).
|
||||||
|
And(
|
||||||
|
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
|
||||||
|
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
|
||||||
|
)
|
||||||
|
ids, err := db.Repository.ListIDs(ctx, query)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ids, 2)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
|
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
|
||||||
@@ -637,3 +700,29 @@ func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
|
|||||||
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
|
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
|
||||||
|
db, database, cleanup := setupTestDBWithMongo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for cursor.Next(ctx) {
|
||||||
|
var idx bson.M
|
||||||
|
require.NoError(t, cursor.Decode(&idx))
|
||||||
|
if idx["name"] == "unique_active_session" {
|
||||||
|
found = true
|
||||||
|
assert.Equal(t, true, idx["unique"])
|
||||||
|
|
||||||
|
partial, ok := idx["partialFilterExpression"].(bson.M)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, bson.M{"isRevoked": false}, partial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "unique_active_session index not found")
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
|
|||||||
if def.Name != "" {
|
if def.Name != "" {
|
||||||
opts.SetName(def.Name)
|
opts.SetName(def.Name)
|
||||||
}
|
}
|
||||||
|
if def.PartialFilter != nil {
|
||||||
|
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
|
||||||
|
}
|
||||||
|
|
||||||
_, err := r.collection.Indexes().CreateOne(
|
_, err := r.collection.Indexes().CreateOne(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
|
|
||||||
type Sort int8
|
type Sort int8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,8 +16,9 @@ type Key struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Definition struct {
|
type Definition struct {
|
||||||
Keys []Key // mandatory, at least one element
|
Keys []Key // mandatory, at least one element
|
||||||
Unique bool // unique constraint?
|
Unique bool // unique constraint?
|
||||||
TTL *int32 // seconds; nil means “no TTL”
|
TTL *int32 // seconds; nil means “no TTL”
|
||||||
Name string // optional explicit name
|
Name string // optional explicit name
|
||||||
|
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.78.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +93,6 @@ require (
|
|||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -267,12 +267,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
|||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
11
api/proto/common/describable/v1/describable.proto
Normal file
11
api/proto/common/describable/v1/describable.proto
Normal 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;
|
||||||
|
}
|
||||||
@@ -7,13 +7,15 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
|
|||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
import "common/money/v1/money.proto";
|
import "common/money/v1/money.proto";
|
||||||
import "common/pagination/v1/cursor.proto";
|
import "common/pagination/v1/cursor.proto";
|
||||||
|
import "common/describable/v1/describable.proto";
|
||||||
|
|
||||||
// Supported blockchain networks for the managed wallets.
|
// Supported blockchain networks for the managed wallets.
|
||||||
enum ChainNetwork {
|
enum ChainNetwork {
|
||||||
CHAIN_NETWORK_UNSPECIFIED = 0;
|
CHAIN_NETWORK_UNSPECIFIED = 0;
|
||||||
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
CHAIN_NETWORK_ETHEREUM_MAINNET = 1;
|
||||||
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
||||||
CHAIN_NETWORK_OTHER_EVM = 3;
|
CHAIN_NETWORK_TRON_MAINNET = 4;
|
||||||
|
CHAIN_NETWORK_TRON_NILE = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ManagedWalletStatus {
|
enum ManagedWalletStatus {
|
||||||
@@ -57,6 +59,7 @@ message ManagedWallet {
|
|||||||
map<string, string> metadata = 7;
|
map<string, string> metadata = 7;
|
||||||
google.protobuf.Timestamp created_at = 8;
|
google.protobuf.Timestamp created_at = 8;
|
||||||
google.protobuf.Timestamp updated_at = 9;
|
google.protobuf.Timestamp updated_at = 9;
|
||||||
|
common.describable.v1.Describable describable = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateManagedWalletRequest {
|
message CreateManagedWalletRequest {
|
||||||
@@ -65,6 +68,7 @@ message CreateManagedWalletRequest {
|
|||||||
string owner_ref = 3;
|
string owner_ref = 3;
|
||||||
Asset asset = 4;
|
Asset asset = 4;
|
||||||
map<string, string> metadata = 5;
|
map<string, string> metadata = 5;
|
||||||
|
common.describable.v1.Describable describable = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateManagedWalletResponse {
|
message CreateManagedWalletResponse {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ api:
|
|||||||
call_timeout_seconds: 5
|
call_timeout_seconds: 5
|
||||||
insecure: true
|
insecure: true
|
||||||
default_asset:
|
default_asset:
|
||||||
chain: ARBITRUM_ONE
|
chain: TRON_MAINNET
|
||||||
token_symbol: USDT
|
token_symbol: USDT
|
||||||
contract_address: ""
|
contract_address: ""
|
||||||
ledger:
|
ledger:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||||
@@ -139,6 +139,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||||
@@ -359,12 +359,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
|||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const (
|
|||||||
ChainNetworkUnspecified ChainNetwork = "unspecified"
|
ChainNetworkUnspecified ChainNetwork = "unspecified"
|
||||||
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
|
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
|
||||||
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
|
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
|
||||||
ChainNetworkOtherEVM ChainNetwork = "other_evm"
|
ChainNetworkTronMainnet ChainNetwork = "tron_mainnet"
|
||||||
|
ChainNetworkTronNile ChainNetwork = "tron_nile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InsufficientNetPolicy mirrors the fee engine policy override.
|
// InsufficientNetPolicy mirrors the fee engine policy override.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
|
|||||||
t.Run("external chain", func(t *testing.T) {
|
t.Run("external chain", func(t *testing.T) {
|
||||||
payload := ExternalChainEndpoint{
|
payload := ExternalChainEndpoint{
|
||||||
Asset: &Asset{
|
Asset: &Asset{
|
||||||
Chain: ChainNetworkOtherEVM,
|
Chain: ChainNetworkEthereumMainnet,
|
||||||
TokenSymbol: "ETH",
|
TokenSymbol: "ETH",
|
||||||
},
|
},
|
||||||
Address: "0x123",
|
Address: "0x123",
|
||||||
@@ -364,7 +364,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
|
|||||||
func TestLegacyEndpointRoundTrip(t *testing.T) {
|
func TestLegacyEndpointRoundTrip(t *testing.T) {
|
||||||
legacy := &LegacyPaymentEndpoint{
|
legacy := &LegacyPaymentEndpoint{
|
||||||
ExternalChain: &ExternalChainEndpoint{
|
ExternalChain: &ExternalChainEndpoint{
|
||||||
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"},
|
Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "DAI", ContractAddress: "0xdef"},
|
||||||
Address: "0x123",
|
Address: "0x123",
|
||||||
Memo: "memo",
|
Memo: "memo",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sresponse
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
@@ -26,6 +27,8 @@ type wallet struct {
|
|||||||
DepositAddress string `json:"depositAddress"`
|
DepositAddress string `json:"depositAddress"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -76,10 +79,31 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
|
|||||||
token := ""
|
token := ""
|
||||||
contract := ""
|
contract := ""
|
||||||
if asset != nil {
|
if asset != nil {
|
||||||
chain = asset.GetChain().String()
|
chain = chainNetworkValue(asset.GetChain())
|
||||||
token = asset.GetTokenSymbol()
|
token = asset.GetTokenSymbol()
|
||||||
contract = asset.GetContractAddress()
|
contract = asset.GetContractAddress()
|
||||||
}
|
}
|
||||||
|
name := ""
|
||||||
|
if d := w.GetDescribable(); d != nil {
|
||||||
|
name = strings.TrimSpace(d.GetName())
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(w.GetMetadata()["name"])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = w.GetWalletRef()
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if d := w.GetDescribable(); d != nil && d.Description != nil {
|
||||||
|
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description == nil {
|
||||||
|
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
return wallet{
|
return wallet{
|
||||||
WalletRef: w.GetWalletRef(),
|
WalletRef: w.GetWalletRef(),
|
||||||
OrganizationRef: w.GetOrganizationRef(),
|
OrganizationRef: w.GetOrganizationRef(),
|
||||||
@@ -92,6 +116,8 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
|
|||||||
DepositAddress: w.GetDepositAddress(),
|
DepositAddress: w.GetDepositAddress(),
|
||||||
Status: w.GetStatus().String(),
|
Status: w.GetStatus().String(),
|
||||||
Metadata: w.GetMetadata(),
|
Metadata: w.GetMetadata(),
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
CreatedAt: tsToString(w.GetCreatedAt()),
|
CreatedAt: tsToString(w.GetCreatedAt()),
|
||||||
UpdatedAt: tsToString(w.GetUpdatedAt()),
|
UpdatedAt: tsToString(w.GetUpdatedAt()),
|
||||||
}
|
}
|
||||||
@@ -115,3 +141,15 @@ func tsToString(ts *timestamppb.Timestamp) string {
|
|||||||
}
|
}
|
||||||
return ts.AsTime().UTC().Format(time.RFC3339)
|
return ts.AsTime().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chainNetworkValue(chain chainv1.ChainNetwork) string {
|
||||||
|
name := chain.String()
|
||||||
|
if !strings.HasPrefix(name, "CHAIN_NETWORK_") {
|
||||||
|
return "unspecified"
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimPrefix(name, "CHAIN_NETWORK_")
|
||||||
|
if trimmed == "" {
|
||||||
|
return "unspecified"
|
||||||
|
}
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre
|
|||||||
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
|
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
|
||||||
return response.Internal(pr.logger, pr.service, err)
|
return response.Internal(pr.logger, pr.service, err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Login confirmation code issued",
|
pr.logger.Info("Login confirmation code issued", zap.String("destination", pr.maskEmail(account.Login)))
|
||||||
zap.String("destination", pr.maskEmail(account.Login)),
|
|
||||||
zap.String("account", account.Login))
|
|
||||||
|
|
||||||
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
|
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,8 +214,10 @@ func parseChainNetwork(value string) (chainv1.ChainNetwork, error) {
|
|||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||||
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
|
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||||
case "OTHER_EVM", "CHAIN_NETWORK_OTHER_EVM":
|
case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||||
|
case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE":
|
||||||
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
|
||||||
case "", "CHAIN_NETWORK_UNSPECIFIED":
|
case "", "CHAIN_NETWORK_UNSPECIFIED":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("chain network must be specified")
|
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("chain network must be specified")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -286,8 +286,10 @@ func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error)
|
|||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||||
case string(srequest.ChainNetworkArbitrumOne):
|
case string(srequest.ChainNetworkArbitrumOne):
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||||
case string(srequest.ChainNetworkOtherEVM):
|
case string(srequest.ChainNetworkTronMainnet):
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||||
|
case string(srequest.ChainNetworkTronNile):
|
||||||
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
|
||||||
default:
|
default:
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
|
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
NATS_PORT: ${NATS_PORT}
|
NATS_PORT: ${NATS_PORT}
|
||||||
NATS_USER: ${NATS_USER}
|
NATS_USER: ${NATS_USER}
|
||||||
NATS_PASSWORD: ${NATS_PASSWORD}
|
NATS_PASSWORD: ${NATS_PASSWORD}
|
||||||
CHAIN_GATEWAY_ARBITRUM_RPC_URL: ${CHAIN_GATEWAY_ARBITRUM_RPC_URL}
|
CHAIN_GATEWAY_RPC_URL: ${CHAIN_GATEWAY_RPC_URL}
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_KEY: ${CHAIN_GATEWAY_SERVICE_WALLET_KEY}
|
CHAIN_GATEWAY_SERVICE_WALLET_KEY: ${CHAIN_GATEWAY_SERVICE_WALLET_KEY}
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS: ${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}
|
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS: ${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}
|
||||||
VAULT_TOKEN_FILE: /run/vault/token
|
VAULT_TOKEN_FILE: /run/vault/token
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ SERVICE_NAMES="${CHAIN_GATEWAY_SERVICE_NAME}"
|
|||||||
REQUIRED_SECRETS=(
|
REQUIRED_SECRETS=(
|
||||||
CHAIN_GATEWAY_MONGO_USER
|
CHAIN_GATEWAY_MONGO_USER
|
||||||
CHAIN_GATEWAY_MONGO_PASSWORD
|
CHAIN_GATEWAY_MONGO_PASSWORD
|
||||||
CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
CHAIN_GATEWAY_RPC_URL
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
CHAIN_GATEWAY_SERVICE_WALLET_KEY
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
||||||
CHAIN_GATEWAY_VAULT_ROLE_ID
|
CHAIN_GATEWAY_VAULT_ROLE_ID
|
||||||
@@ -46,7 +46,7 @@ b64enc() {
|
|||||||
|
|
||||||
CHAIN_GATEWAY_MONGO_USER_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_USER}")"
|
CHAIN_GATEWAY_MONGO_USER_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_USER}")"
|
||||||
CHAIN_GATEWAY_MONGO_PASSWORD_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_PASSWORD}")"
|
CHAIN_GATEWAY_MONGO_PASSWORD_B64="$(b64enc "${CHAIN_GATEWAY_MONGO_PASSWORD}")"
|
||||||
CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64="$(b64enc "${CHAIN_GATEWAY_ARBITRUM_RPC_URL}")"
|
CHAIN_GATEWAY_RPC_URL_B64="$(b64enc "${CHAIN_GATEWAY_RPC_URL}")"
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_KEY}")"
|
CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_KEY}")"
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}")"
|
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$(b64enc "${CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS}")"
|
||||||
CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$(b64enc "${CHAIN_GATEWAY_VAULT_ROLE_ID}")"
|
CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$(b64enc "${CHAIN_GATEWAY_VAULT_ROLE_ID}")"
|
||||||
@@ -84,7 +84,7 @@ ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \
|
|||||||
SERVICES_LINE="$SERVICES_LINE" \
|
SERVICES_LINE="$SERVICES_LINE" \
|
||||||
CHAIN_GATEWAY_MONGO_USER_B64="$CHAIN_GATEWAY_MONGO_USER_B64" \
|
CHAIN_GATEWAY_MONGO_USER_B64="$CHAIN_GATEWAY_MONGO_USER_B64" \
|
||||||
CHAIN_GATEWAY_MONGO_PASSWORD_B64="$CHAIN_GATEWAY_MONGO_PASSWORD_B64" \
|
CHAIN_GATEWAY_MONGO_PASSWORD_B64="$CHAIN_GATEWAY_MONGO_PASSWORD_B64" \
|
||||||
CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64="$CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64" \
|
CHAIN_GATEWAY_RPC_URL_B64="$CHAIN_GATEWAY_RPC_URL_B64" \
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64" \
|
CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64="$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64" \
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64" \
|
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64="$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64" \
|
||||||
CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$CHAIN_GATEWAY_VAULT_ROLE_ID_B64" \
|
CHAIN_GATEWAY_VAULT_ROLE_ID_B64="$CHAIN_GATEWAY_VAULT_ROLE_ID_B64" \
|
||||||
@@ -135,7 +135,7 @@ decode_b64() {
|
|||||||
|
|
||||||
CHAIN_GATEWAY_MONGO_USER="$(decode_b64 "$CHAIN_GATEWAY_MONGO_USER_B64")"
|
CHAIN_GATEWAY_MONGO_USER="$(decode_b64 "$CHAIN_GATEWAY_MONGO_USER_B64")"
|
||||||
CHAIN_GATEWAY_MONGO_PASSWORD="$(decode_b64 "$CHAIN_GATEWAY_MONGO_PASSWORD_B64")"
|
CHAIN_GATEWAY_MONGO_PASSWORD="$(decode_b64 "$CHAIN_GATEWAY_MONGO_PASSWORD_B64")"
|
||||||
CHAIN_GATEWAY_ARBITRUM_RPC_URL="$(decode_b64 "$CHAIN_GATEWAY_ARBITRUM_RPC_URL_B64")"
|
CHAIN_GATEWAY_RPC_URL="$(decode_b64 "$CHAIN_GATEWAY_RPC_URL_B64")"
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64")"
|
CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_KEY_B64")"
|
||||||
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64")"
|
CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(decode_b64 "$CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS_B64")"
|
||||||
CHAIN_GATEWAY_VAULT_ROLE_ID="$(decode_b64 "$CHAIN_GATEWAY_VAULT_ROLE_ID_B64")"
|
CHAIN_GATEWAY_VAULT_ROLE_ID="$(decode_b64 "$CHAIN_GATEWAY_VAULT_ROLE_ID_B64")"
|
||||||
@@ -145,7 +145,7 @@ NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")"
|
|||||||
NATS_URL="$(decode_b64 "$NATS_URL_B64")"
|
NATS_URL="$(decode_b64 "$NATS_URL_B64")"
|
||||||
|
|
||||||
export CHAIN_GATEWAY_MONGO_USER CHAIN_GATEWAY_MONGO_PASSWORD
|
export CHAIN_GATEWAY_MONGO_USER CHAIN_GATEWAY_MONGO_PASSWORD
|
||||||
export CHAIN_GATEWAY_ARBITRUM_RPC_URL
|
export CHAIN_GATEWAY_RPC_URL
|
||||||
export CHAIN_GATEWAY_SERVICE_WALLET_KEY CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
export CHAIN_GATEWAY_SERVICE_WALLET_KEY CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
|
||||||
export CHAIN_GATEWAY_VAULT_ROLE_ID CHAIN_GATEWAY_VAULT_SECRET_ID
|
export CHAIN_GATEWAY_VAULT_ROLE_ID CHAIN_GATEWAY_VAULT_SECRET_ID
|
||||||
export NATS_USER NATS_PASSWORD NATS_URL
|
export NATS_USER NATS_PASSWORD NATS_URL
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ CHAIN_GATEWAY_VAULT_SECRET_PATH="${CHAIN_GATEWAY_VAULT_SECRET_PATH:?missing CHAI
|
|||||||
export CHAIN_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" user)"
|
export CHAIN_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" user)"
|
||||||
export CHAIN_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" password)"
|
export CHAIN_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" password)"
|
||||||
|
|
||||||
export CHAIN_GATEWAY_ARBITRUM_RPC_URL="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_RPC_SECRET_PATH}" arbitrum_rpc_url)"
|
export CHAIN_GATEWAY_RPC_URL="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_RPC_SECRET_PATH}" tron_rpc_url)"
|
||||||
|
|
||||||
export CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" private_key)"
|
export CHAIN_GATEWAY_SERVICE_WALLET_KEY="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" private_key)"
|
||||||
export CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" address || true)"
|
export CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_WALLET_SECRET_PATH}" address || true)"
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ class ChangePassword {
|
|||||||
@JsonKey(name: 'new')
|
@JsonKey(name: 'new')
|
||||||
final String newPassword;
|
final String newPassword;
|
||||||
|
|
||||||
const ChangePassword({required this.oldPassword, required this.newPassword});
|
final String deviceId;
|
||||||
|
|
||||||
|
const ChangePassword({required this.oldPassword, required this.newPassword, required this.deviceId});
|
||||||
|
|
||||||
factory ChangePassword.fromJson(Map<String, dynamic> json) => _$ChangePasswordFromJson(json);
|
factory ChangePassword.fromJson(Map<String, dynamic> json) => _$ChangePasswordFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$ChangePasswordToJson(this);
|
Map<String, dynamic> toJson() => _$ChangePasswordToJson(this);
|
||||||
|
|||||||
25
frontend/pshared/lib/api/requests/payment/quotes.dart
Normal file
25
frontend/pshared/lib/api/requests/payment/quotes.dart
Normal 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);
|
||||||
|
}
|
||||||
20
frontend/pshared/lib/api/requests/username.dart
Normal file
20
frontend/pshared/lib/api/requests/username.dart
Normal 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);
|
||||||
|
}
|
||||||
27
frontend/pshared/lib/api/responses/confirmation.dart
Normal file
27
frontend/pshared/lib/api/responses/confirmation.dart
Normal 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);
|
||||||
|
}
|
||||||
20
frontend/pshared/lib/api/responses/payment/quotes.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/quotes.dart
Normal 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);
|
||||||
|
}
|
||||||
24
frontend/pshared/lib/data/dto/payment/quote_aggregate.dart
Normal file
24
frontend/pshared/lib/data/dto/payment/quote_aggregate.dart
Normal 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);
|
||||||
|
}
|
||||||
23
frontend/pshared/lib/data/dto/payment/quotes.dart
Normal file
23
frontend/pshared/lib/data/dto/payment/quotes.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ class WalletDTO {
|
|||||||
final WalletAssetDTO asset;
|
final WalletAssetDTO asset;
|
||||||
final String depositAddress;
|
final String depositAddress;
|
||||||
final String status;
|
final String status;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
final Map<String, String>? metadata;
|
final Map<String, String>? metadata;
|
||||||
final String? createdAt;
|
final String? createdAt;
|
||||||
final String? updatedAt;
|
final String? updatedAt;
|
||||||
@@ -24,6 +26,8 @@ class WalletDTO {
|
|||||||
required this.asset,
|
required this.asset,
|
||||||
required this.depositAddress,
|
required this.depositAddress,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
this.metadata,
|
this.metadata,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
|
|||||||
@@ -83,17 +83,21 @@ String fxSideToValue(FxSide side) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChainNetwork chainNetworkFromValue(String? value) {
|
ChainNetwork chainNetworkFromValue(String? value) {
|
||||||
switch (value) {
|
final raw = value ?? '';
|
||||||
|
final normalized = _normalizeChainNetwork(raw);
|
||||||
|
switch (normalized) {
|
||||||
case 'ethereum_mainnet':
|
case 'ethereum_mainnet':
|
||||||
return ChainNetwork.ethereumMainnet;
|
return ChainNetwork.ethereumMainnet;
|
||||||
case 'arbitrum_one':
|
case 'arbitrum_one':
|
||||||
return ChainNetwork.arbitrumOne;
|
return ChainNetwork.arbitrumOne;
|
||||||
case 'other_evm':
|
case 'tron_mainnet':
|
||||||
return ChainNetwork.otherEvm;
|
return ChainNetwork.tronMainnet;
|
||||||
|
case 'tron_nile':
|
||||||
|
return ChainNetwork.tronNile;
|
||||||
case 'unspecified':
|
case 'unspecified':
|
||||||
return ChainNetwork.unspecified;
|
return ChainNetwork.unspecified;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError('Unknown ChainNetwork value: $value');
|
throw ArgumentError('Unknown ChainNetwork value: $raw');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +107,28 @@ String chainNetworkToValue(ChainNetwork chain) {
|
|||||||
return 'ethereum_mainnet';
|
return 'ethereum_mainnet';
|
||||||
case ChainNetwork.arbitrumOne:
|
case ChainNetwork.arbitrumOne:
|
||||||
return 'arbitrum_one';
|
return 'arbitrum_one';
|
||||||
case ChainNetwork.otherEvm:
|
case ChainNetwork.tronMainnet:
|
||||||
return 'other_evm';
|
return 'tron_mainnet';
|
||||||
|
case ChainNetwork.tronNile:
|
||||||
|
return 'tron_nile';
|
||||||
case ChainNetwork.unspecified:
|
case ChainNetwork.unspecified:
|
||||||
return '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) {
|
InsufficientNetPolicy insufficientNetPolicyFromValue(String? value) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'block_posting':
|
case 'block_posting':
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/pshared/lib/data/mapper/payment/quotes.dart
Normal file
21
frontend/pshared/lib/data/mapper/payment/quotes.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:pshared/data/dto/payment/quotes.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/quote_aggregate.dart';
|
||||||
|
import 'package:pshared/models/payment/quotes.dart';
|
||||||
|
|
||||||
|
|
||||||
|
extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
|
||||||
|
PaymentQuotes toDomain() => PaymentQuotes(
|
||||||
|
quoteRef: quoteRef,
|
||||||
|
aggregate: aggregate?.toDomain(),
|
||||||
|
quotes: quotes?.map((quote) => quote.toDomain()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PaymentQuotesMapper on PaymentQuotes {
|
||||||
|
PaymentQuotesDTO toDTO() => PaymentQuotesDTO(
|
||||||
|
quoteRef: quoteRef,
|
||||||
|
aggregate: aggregate?.toDTO(),
|
||||||
|
quotes: quotes?.map((quote) => quote.toDTO()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/pshared/lib/data/mapper/wallet/asset.dart
Normal file
20
frontend/pshared/lib/data/mapper/wallet/asset.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:pshared/data/dto/wallet/asset.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/enums.dart';
|
||||||
|
import 'package:pshared/models/wallet/wallet.dart';
|
||||||
|
|
||||||
|
|
||||||
|
extension WalletAssetDTOMapper on WalletAssetDTO {
|
||||||
|
WalletAsset toDomain() => WalletAsset(
|
||||||
|
chain: chainNetworkFromValue(chain),
|
||||||
|
tokenSymbol: tokenSymbol,
|
||||||
|
contractAddress: contractAddress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WalletAssetMapper on WalletAsset {
|
||||||
|
WalletAssetDTO toDTO() => WalletAssetDTO(
|
||||||
|
chain: chainNetworkToValue(chain),
|
||||||
|
tokenSymbol: tokenSymbol,
|
||||||
|
contractAddress: contractAddress,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import 'package:pshared/data/dto/wallet/balance.dart';
|
import 'package:pshared/data/dto/wallet/balance.dart';
|
||||||
import 'package:pshared/data/dto/wallet/wallet.dart';
|
import 'package:pshared/data/dto/wallet/wallet.dart';
|
||||||
|
import 'package:pshared/data/mapper/wallet/asset.dart';
|
||||||
import 'package:pshared/data/mapper/wallet/balance.dart';
|
import 'package:pshared/data/mapper/wallet/balance.dart';
|
||||||
import 'package:pshared/data/mapper/wallet/money.dart';
|
import 'package:pshared/data/mapper/wallet/money.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
import 'package:pshared/models/describable.dart';
|
||||||
import 'package:pshared/models/wallet/wallet.dart';
|
import 'package:pshared/models/wallet/wallet.dart';
|
||||||
|
|
||||||
|
|
||||||
extension WalletDTOMapper on WalletDTO {
|
extension WalletDTOMapper on WalletDTO {
|
||||||
WalletModel toDomain({WalletBalanceDTO? balance}) => WalletModel(
|
WalletModel toDomain({WalletBalanceDTO? balance}) => WalletModel(
|
||||||
walletRef: walletRef,
|
walletRef: walletRef,
|
||||||
organizationRef: organizationRef,
|
organizationRef: organizationRef,
|
||||||
ownerRef: ownerRef,
|
ownerRef: ownerRef,
|
||||||
asset: WalletAsset(
|
asset: asset.toDomain(),
|
||||||
chain: asset.chain,
|
|
||||||
tokenSymbol: asset.tokenSymbol,
|
|
||||||
contractAddress: asset.contractAddress,
|
|
||||||
),
|
|
||||||
depositAddress: depositAddress,
|
depositAddress: depositAddress,
|
||||||
status: status,
|
status: status,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
@@ -24,8 +20,10 @@ extension WalletDTOMapper on WalletDTO {
|
|||||||
balance: balance?.toDomain(),
|
balance: balance?.toDomain(),
|
||||||
availableMoney: balance?.available?.toDomain(),
|
availableMoney: balance?.available?.toDomain(),
|
||||||
describable: newDescribable(
|
describable: newDescribable(
|
||||||
name: metadata?['name'] ?? 'Crypto Wallet',
|
name: name.isNotEmpty ? name : (metadata?['name']?.toString() ?? ''),
|
||||||
description: metadata?['description'],
|
description: (description != null && description!.isNotEmpty)
|
||||||
|
? description
|
||||||
|
: metadata?['description'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,13 @@
|
|||||||
"description": "Label for the Arbitrum One network"
|
"description": "Label for the Arbitrum One network"
|
||||||
},
|
},
|
||||||
|
|
||||||
"chainNetworkOtherEvm": "Other EVM chain",
|
"chainNetworkTronMainnet": "Tron Mainnet",
|
||||||
"@chainNetworkOtherEvm": {
|
"@chainNetworkTronMainnet": {
|
||||||
"description": "Label for any other EVM-compatible network"
|
"description": "Label for the Tron mainnet network"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chainNetworkTronNile": "Tron Nile (testnet)",
|
||||||
|
"@chainNetworkTronNile": {
|
||||||
|
"description": "Label for the Tron Nile testnet network"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,13 @@
|
|||||||
"description": "Label for the Arbitrum One network"
|
"description": "Label for the Arbitrum One network"
|
||||||
},
|
},
|
||||||
|
|
||||||
"chainNetworkOtherEvm": "Другая EVM сеть",
|
"chainNetworkTronMainnet": "Tron Mainnet",
|
||||||
"@chainNetworkOtherEvm": {
|
"@chainNetworkTronMainnet": {
|
||||||
"description": "Label for any other EVM-compatible network"
|
"description": "Label for the Tron mainnet network"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chainNetworkTronNile": "Tron Nile (testnet)",
|
||||||
|
"@chainNetworkTronNile": {
|
||||||
|
"description": "Label for the Tron Nile testnet network"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ class AccountBase implements StorableDescribable {
|
|||||||
DateTime get updatedAt => storable.updatedAt;
|
DateTime get updatedAt => storable.updatedAt;
|
||||||
@override
|
@override
|
||||||
String get name => describable.name;
|
String get name => describable.name;
|
||||||
|
String get fullName {
|
||||||
|
final first = describable.name.trim();
|
||||||
|
final last = lastName.trim();
|
||||||
|
|
||||||
|
if (last.isEmpty) return first;
|
||||||
|
if (first.isEmpty) return last;
|
||||||
|
return '$first $last';
|
||||||
|
}
|
||||||
@override
|
@override
|
||||||
String? get description => describable.description;
|
String? get description => describable.description;
|
||||||
|
|
||||||
@@ -32,7 +40,7 @@ class AccountBase implements StorableDescribable {
|
|||||||
required this.lastName,
|
required this.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get nameInitials => getNameInitials(describable.name);
|
String get nameInitials => getNameInitials(fullName);
|
||||||
|
|
||||||
AccountBase copyWith({
|
AccountBase copyWith({
|
||||||
Describable? describable,
|
Describable? describable,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class PendingLogin {
|
|||||||
final String destination;
|
final String destination;
|
||||||
final int ttlSeconds;
|
final int ttlSeconds;
|
||||||
final SessionIdentifier session;
|
final SessionIdentifier session;
|
||||||
|
final int? cooldownSeconds;
|
||||||
|
final DateTime? cooldownUntil;
|
||||||
|
|
||||||
const PendingLogin({
|
const PendingLogin({
|
||||||
required this.account,
|
required this.account,
|
||||||
@@ -18,6 +20,8 @@ class PendingLogin {
|
|||||||
required this.destination,
|
required this.destination,
|
||||||
required this.ttlSeconds,
|
required this.ttlSeconds,
|
||||||
required this.session,
|
required this.session,
|
||||||
|
this.cooldownSeconds,
|
||||||
|
this.cooldownUntil,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PendingLogin.fromResponse(
|
factory PendingLogin.fromResponse(
|
||||||
@@ -30,4 +34,30 @@ class PendingLogin {
|
|||||||
ttlSeconds: response.ttlSeconds,
|
ttlSeconds: response.ttlSeconds,
|
||||||
session: session,
|
session: session,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
PendingLogin copyWith({
|
||||||
|
Account? account,
|
||||||
|
TokenData? pendingToken,
|
||||||
|
String? destination,
|
||||||
|
int? ttlSeconds,
|
||||||
|
SessionIdentifier? session,
|
||||||
|
int? cooldownSeconds,
|
||||||
|
DateTime? cooldownUntil,
|
||||||
|
bool clearCooldown = false,
|
||||||
|
}) {
|
||||||
|
return PendingLogin(
|
||||||
|
account: account ?? this.account,
|
||||||
|
pendingToken: pendingToken ?? this.pendingToken,
|
||||||
|
destination: destination ?? this.destination,
|
||||||
|
ttlSeconds: ttlSeconds ?? this.ttlSeconds,
|
||||||
|
session: session ?? this.session,
|
||||||
|
cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds,
|
||||||
|
cooldownUntil: clearCooldown ? null : cooldownUntil ?? this.cooldownUntil,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get cooldownRemainingSeconds {
|
||||||
|
final remaining = cooldownUntil?.difference(DateTime.now()).inSeconds ?? 0;
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
enum ChainNetwork { unspecified, ethereumMainnet, arbitrumOne, otherEvm }
|
enum ChainNetwork {
|
||||||
|
unspecified,
|
||||||
|
ethereumMainnet,
|
||||||
|
arbitrumOne,
|
||||||
|
tronMainnet,
|
||||||
|
tronNile
|
||||||
|
}
|
||||||
|
|||||||
16
frontend/pshared/lib/models/payment/quote_aggregate.dart
Normal file
16
frontend/pshared/lib/models/payment/quote_aggregate.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:pshared/models/payment/money.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentQuoteAggregate {
|
||||||
|
final List<Money>? debitAmounts;
|
||||||
|
final List<Money>? expectedSettlementAmounts;
|
||||||
|
final List<Money>? expectedFeeTotals;
|
||||||
|
final List<Money>? networkFeeTotals;
|
||||||
|
|
||||||
|
const PaymentQuoteAggregate({
|
||||||
|
required this.debitAmounts,
|
||||||
|
required this.expectedSettlementAmounts,
|
||||||
|
required this.expectedFeeTotals,
|
||||||
|
required this.networkFeeTotals,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
frontend/pshared/lib/models/payment/quotes.dart
Normal file
15
frontend/pshared/lib/models/payment/quotes.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
|
import 'package:pshared/models/payment/quote_aggregate.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentQuotes {
|
||||||
|
final String quoteRef;
|
||||||
|
final PaymentQuoteAggregate? aggregate;
|
||||||
|
final List<PaymentQuote>? quotes;
|
||||||
|
|
||||||
|
const PaymentQuotes({
|
||||||
|
required this.quoteRef,
|
||||||
|
required this.aggregate,
|
||||||
|
required this.quotes,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import 'package:pshared/models/describable.dart';
|
||||||
|
import 'package:pshared/models/payment/chain_network.dart';
|
||||||
import 'package:pshared/models/wallet/balance.dart';
|
import 'package:pshared/models/wallet/balance.dart';
|
||||||
import 'package:pshared/models/wallet/money.dart';
|
import 'package:pshared/models/wallet/money.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class WalletAsset {
|
class WalletAsset {
|
||||||
final String chain;
|
final ChainNetwork chain;
|
||||||
final String tokenSymbol;
|
final String tokenSymbol;
|
||||||
final String contractAddress;
|
final String contractAddress;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:pshared/api/requests/signup.dart';
|
|||||||
import 'package:pshared/api/requests/login_data.dart';
|
import 'package:pshared/api/requests/login_data.dart';
|
||||||
import 'package:pshared/config/constants.dart';
|
import 'package:pshared/config/constants.dart';
|
||||||
import 'package:pshared/models/account/account.dart';
|
import 'package:pshared/models/account/account.dart';
|
||||||
|
import 'package:pshared/api/responses/confirmation.dart';
|
||||||
import 'package:pshared/models/auth/login_outcome.dart';
|
import 'package:pshared/models/auth/login_outcome.dart';
|
||||||
import 'package:pshared/models/auth/pending_login.dart';
|
import 'package:pshared/models/auth/pending_login.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
import 'package:pshared/models/describable.dart';
|
||||||
@@ -101,8 +102,8 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
if (pending == null) {
|
if (pending == null) {
|
||||||
throw Exception('Pending login data is missing');
|
throw Exception('Pending login data is missing');
|
||||||
}
|
}
|
||||||
await VerificationService.requestLoginCode(pending);
|
final confirmation = await VerificationService.requestLoginCode(pending);
|
||||||
_pendingLogin = pending;
|
_pendingLogin = _applyConfirmationMeta(pending, confirmation);
|
||||||
_authState = AuthState.idle;
|
_authState = AuthState.idle;
|
||||||
_setResource(_resource.copyWith(isLoading: false));
|
_setResource(_resource.copyWith(isLoading: false));
|
||||||
}
|
}
|
||||||
@@ -114,6 +115,27 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingLogin _applyConfirmationMeta(PendingLogin pending, ConfirmationResponse confirmation) {
|
||||||
|
final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds;
|
||||||
|
final destination = confirmation.destination.isNotEmpty ? confirmation.destination : pending.destination;
|
||||||
|
final cooldownSeconds = confirmation.cooldownSeconds;
|
||||||
|
|
||||||
|
return pending.copyWith(
|
||||||
|
ttlSeconds: ttlSeconds,
|
||||||
|
destination: destination,
|
||||||
|
cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null,
|
||||||
|
cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null,
|
||||||
|
clearCooldown: cooldownSeconds <= 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePendingLogin(ConfirmationResponse confirmation) {
|
||||||
|
final pending = _pendingLogin;
|
||||||
|
if (pending == null) return;
|
||||||
|
_pendingLogin = _applyConfirmationMeta(pending, confirmation);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void completePendingLogin(Account account) {
|
void completePendingLogin(Account account) {
|
||||||
_pendingLogin = null;
|
_pendingLogin = null;
|
||||||
_authState = AuthState.ready;
|
_authState = AuthState.ready;
|
||||||
@@ -181,6 +203,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<Account?> update({
|
Future<Account?> update({
|
||||||
Describable? describable,
|
Describable? describable,
|
||||||
|
String? lastName,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
String? notificationFrequency,
|
String? notificationFrequency,
|
||||||
@@ -191,6 +214,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
final updated = await AccountService.update(
|
final updated = await AccountService.update(
|
||||||
account!.copyWith(
|
account!.copyWith(
|
||||||
describable: describable,
|
describable: describable,
|
||||||
|
lastName: lastName,
|
||||||
avatarUrl: () => avatarUrl ?? account!.avatarUrl,
|
avatarUrl: () => avatarUrl ?? account!.avatarUrl,
|
||||||
locale: locale ?? account!.locale,
|
locale: locale ?? account!.locale,
|
||||||
),
|
),
|
||||||
@@ -228,6 +252,14 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Account?> resetUsername(String userName, {String? lastName}) async {
|
||||||
|
if (account == null) throw ErrorUnauthorized();
|
||||||
|
return update(
|
||||||
|
describable: account!.describable.copyWith(name: userName),
|
||||||
|
lastName: lastName ?? account!.lastName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> forgotPassword(String email) async {
|
Future<void> forgotPassword(String email) async {
|
||||||
_setResource(_resource.copyWith(isLoading: true, error: null));
|
_setResource(_resource.copyWith(isLoading: true, error: null));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:pshared/service/device_id.dart';
|
||||||
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
@@ -66,7 +67,11 @@ class AccountService {
|
|||||||
return _getAccount(AuthorizationService.getPATCHResponse(
|
return _getAccount(AuthorizationService.getPATCHResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
'password',
|
'password',
|
||||||
ChangePassword(oldPassword: oldPassword, newPassword: newPassword).toJson(),
|
ChangePassword(
|
||||||
|
oldPassword: oldPassword,
|
||||||
|
newPassword: newPassword,
|
||||||
|
deviceId: await DeviceIdManager.getDeviceId(),
|
||||||
|
).toJson(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
import 'package:pshared/api/requests/payment/quote.dart';
|
import 'package:pshared/api/requests/payment/quote.dart';
|
||||||
|
import 'package:pshared/api/requests/payment/quotes.dart';
|
||||||
import 'package:pshared/api/responses/payment/quotation.dart';
|
import 'package:pshared/api/responses/payment/quotation.dart';
|
||||||
|
import 'package:pshared/api/responses/payment/quotes.dart';
|
||||||
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/quotes.dart';
|
||||||
import 'package:pshared/models/payment/quote.dart';
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
|
import 'package:pshared/models/payment/quotes.dart';
|
||||||
import 'package:pshared/service/authorization/service.dart';
|
import 'package:pshared/service/authorization/service.dart';
|
||||||
import 'package:pshared/service/services.dart';
|
import 'package:pshared/service/services.dart';
|
||||||
|
|
||||||
@@ -21,4 +25,14 @@ class QuotationService {
|
|||||||
);
|
);
|
||||||
return PaymentQuoteResponse.fromJson(response).quote.toDomain();
|
return PaymentQuoteResponse.fromJson(response).quote.toDomain();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<PaymentQuotes> getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async {
|
||||||
|
_logger.fine('Quoting payments for organization $organizationRef');
|
||||||
|
final response = await AuthorizationService.getPOSTResponse(
|
||||||
|
_objectType,
|
||||||
|
'/multiquote/$organizationRef',
|
||||||
|
request.toJson(),
|
||||||
|
);
|
||||||
|
return PaymentQuotesResponse.fromJson(response).quote.toDomain();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:pshared/api/responses/login.dart';
|
|||||||
import 'package:pshared/data/mapper/session_identifier.dart';
|
import 'package:pshared/data/mapper/session_identifier.dart';
|
||||||
import 'package:pshared/models/account/account.dart';
|
import 'package:pshared/models/account/account.dart';
|
||||||
import 'package:pshared/data/mapper/account/account.dart';
|
import 'package:pshared/data/mapper/account/account.dart';
|
||||||
|
import 'package:pshared/api/responses/confirmation.dart';
|
||||||
import 'package:pshared/models/auth/pending_login.dart';
|
import 'package:pshared/models/auth/pending_login.dart';
|
||||||
import 'package:pshared/service/authorization/storage.dart';
|
import 'package:pshared/service/authorization/storage.dart';
|
||||||
import 'package:pshared/service/services.dart';
|
import 'package:pshared/service/services.dart';
|
||||||
@@ -15,24 +16,26 @@ class VerificationService {
|
|||||||
static final _logger = Logger('service.verification');
|
static final _logger = Logger('service.verification');
|
||||||
static const String _objectType = Services.confirmations;
|
static const String _objectType = Services.confirmations;
|
||||||
|
|
||||||
static Future<void> requestLoginCode(PendingLogin pending, {String? destination}) async {
|
static Future<ConfirmationResponse> requestLoginCode(PendingLogin pending, {String? destination}) async {
|
||||||
_logger.fine('Requesting login confirmation code');
|
_logger.fine('Requesting login confirmation code');
|
||||||
await getPOSTResponse(
|
final response = await getPOSTResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
'',
|
'',
|
||||||
LoginConfirmationRequest(destination: destination).toJson(),
|
LoginConfirmationRequest(destination: destination).toJson(),
|
||||||
authToken: pending.pendingToken.token,
|
authToken: pending.pendingToken.token,
|
||||||
);
|
);
|
||||||
|
return ConfirmationResponse.fromJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> resendLoginCode(PendingLogin pending, {String? destination}) async {
|
static Future<ConfirmationResponse> resendLoginCode(PendingLogin pending, {String? destination}) async {
|
||||||
_logger.fine('Resending login confirmation code');
|
_logger.fine('Resending login confirmation code');
|
||||||
await getPOSTResponse(
|
final response = await getPOSTResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
'/resend',
|
'/resend',
|
||||||
LoginConfirmationRequest(destination: destination).toJson(),
|
LoginConfirmationRequest(destination: destination).toJson(),
|
||||||
authToken: pending.pendingToken.token,
|
authToken: pending.pendingToken.token,
|
||||||
);
|
);
|
||||||
|
return ConfirmationResponse.fromJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Account> confirmLoginCode({
|
static Future<Account> confirmLoginCode({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'package:pshared/generated/i18n/ps_localizations.dart';
|
|
||||||
import 'package:pshared/models/payment/chain_network.dart';
|
import 'package:pshared/models/payment/chain_network.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/generated/i18n/ps_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Localized labels for [ChainNetwork] values.
|
/// Localized labels for [ChainNetwork] values.
|
||||||
extension ChainNetworkL10n on ChainNetwork {
|
extension ChainNetworkL10n on ChainNetwork {
|
||||||
/// Returns a human-readable, localized name for the chain.
|
/// Returns a human-readable, localized name for the chain.
|
||||||
@@ -13,8 +15,10 @@ extension ChainNetworkL10n on ChainNetwork {
|
|||||||
return l10n.chainNetworkEthereumMainnet;
|
return l10n.chainNetworkEthereumMainnet;
|
||||||
case ChainNetwork.arbitrumOne:
|
case ChainNetwork.arbitrumOne:
|
||||||
return l10n.chainNetworkArbitrumOne;
|
return l10n.chainNetworkArbitrumOne;
|
||||||
case ChainNetwork.otherEvm:
|
case ChainNetwork.tronMainnet:
|
||||||
return l10n.chainNetworkOtherEvm;
|
return l10n.chainNetworkTronMainnet;
|
||||||
|
case ChainNetwork.tronNile:
|
||||||
|
return l10n.chainNetworkTronNile;
|
||||||
case ChainNetwork.unspecified:
|
case ChainNetwork.unspecified:
|
||||||
return l10n.chainNetworkUnspecified;
|
return l10n.chainNetworkUnspecified;
|
||||||
}
|
}
|
||||||
|
|||||||
56
frontend/pshared/lib/widgets/password/confirm_field.dart
Normal file
56
frontend/pshared/lib/widgets/password/confirm_field.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmPasswordField extends StatelessWidget {
|
||||||
|
const ConfirmPasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.fieldWidth,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.newPasswordController,
|
||||||
|
required this.missingPasswordError,
|
||||||
|
required this.passwordsDoNotMatchError,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.onToggleVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final double fieldWidth;
|
||||||
|
final bool isEnabled;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final TextEditingController newPasswordController;
|
||||||
|
final String missingPasswordError;
|
||||||
|
final String passwordsDoNotMatchError;
|
||||||
|
final bool obscureText;
|
||||||
|
final VoidCallback onToggleVisibility;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: fieldWidth,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
enabled: isEnabled,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: confirmPasswordLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: onToggleVisibility,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) return missingPasswordError;
|
||||||
|
if (value != newPasswordController.text) {
|
||||||
|
return passwordsDoNotMatchError;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
frontend/pshared/lib/widgets/password/field.dart
Normal file
46
frontend/pshared/lib/widgets/password/field.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordField extends StatelessWidget {
|
||||||
|
const PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.labelText,
|
||||||
|
required this.fieldWidth,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.onToggleVisibility,
|
||||||
|
required this.validator,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String labelText;
|
||||||
|
final double fieldWidth;
|
||||||
|
final bool isEnabled;
|
||||||
|
final bool obscureText;
|
||||||
|
final VoidCallback onToggleVisibility;
|
||||||
|
final String? Function(String?) validator;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: fieldWidth,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
enabled: isEnabled,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: onToggleVisibility,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: validator,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
frontend/pshared/lib/widgets/password/fields.dart
Normal file
87
frontend/pshared/lib/widgets/password/fields.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/widgets/password/confirm_field.dart';
|
||||||
|
import 'package:pshared/widgets/password/field.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordFields extends StatelessWidget {
|
||||||
|
const PasswordFields({
|
||||||
|
super.key,
|
||||||
|
required this.oldPasswordController,
|
||||||
|
required this.newPasswordController,
|
||||||
|
required this.confirmPasswordController,
|
||||||
|
required this.oldPasswordLabel,
|
||||||
|
required this.newPasswordLabel,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.missingPasswordError,
|
||||||
|
required this.passwordsDoNotMatchError,
|
||||||
|
required this.fieldWidth,
|
||||||
|
required this.gapSmall,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.showOldPassword,
|
||||||
|
required this.showNewPassword,
|
||||||
|
required this.showConfirmPassword,
|
||||||
|
required this.onToggleOldPassword,
|
||||||
|
required this.onToggleNewPassword,
|
||||||
|
required this.onToggleConfirmPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController oldPasswordController;
|
||||||
|
final TextEditingController newPasswordController;
|
||||||
|
final TextEditingController confirmPasswordController;
|
||||||
|
final String oldPasswordLabel;
|
||||||
|
final String newPasswordLabel;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final String missingPasswordError;
|
||||||
|
final String passwordsDoNotMatchError;
|
||||||
|
final double fieldWidth;
|
||||||
|
final double gapSmall;
|
||||||
|
final bool isEnabled;
|
||||||
|
final bool showOldPassword;
|
||||||
|
final bool showNewPassword;
|
||||||
|
final bool showConfirmPassword;
|
||||||
|
final VoidCallback onToggleOldPassword;
|
||||||
|
final VoidCallback onToggleNewPassword;
|
||||||
|
final VoidCallback onToggleConfirmPassword;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
PasswordField(
|
||||||
|
controller: oldPasswordController,
|
||||||
|
labelText: oldPasswordLabel,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
obscureText: !showOldPassword,
|
||||||
|
onToggleVisibility: onToggleOldPassword,
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.isEmpty) ? missingPasswordError : null,
|
||||||
|
),
|
||||||
|
SizedBox(height: gapSmall),
|
||||||
|
PasswordField(
|
||||||
|
controller: newPasswordController,
|
||||||
|
labelText: newPasswordLabel,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
obscureText: !showNewPassword,
|
||||||
|
onToggleVisibility: onToggleNewPassword,
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.isEmpty) ? missingPasswordError : null,
|
||||||
|
),
|
||||||
|
SizedBox(height: gapSmall),
|
||||||
|
ConfirmPasswordField(
|
||||||
|
controller: confirmPasswordController,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
confirmPasswordLabel: confirmPasswordLabel,
|
||||||
|
newPasswordController: newPasswordController,
|
||||||
|
missingPasswordError: missingPasswordError,
|
||||||
|
passwordsDoNotMatchError: passwordsDoNotMatchError,
|
||||||
|
obscureText: !showConfirmPassword,
|
||||||
|
onToggleVisibility: onToggleConfirmPassword,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user