16 Commits

Author SHA1 Message Date
93bd0bf002 Merge pull request 'fixed config + improved logging' (#132) from tron-132 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #132
2025-12-23 15:28:27 +00:00
Stephan D
946bfa217c fixed config + improved logging 2025-12-23 16:26:06 +01:00
318255405b Merge pull request 'Migration to TRON chain' (#130) from tron-130 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
Reviewed-on: #130
2025-12-23 14:42:30 +00:00
Stephan D
19d4ee1d33 Migration to TRON chain 2025-12-23 15:42:07 +01:00
bc6a56c129 Merge pull request 'Implemented cooldown before User is able to resend confirmation code for 2fa' (#128) from SEND012 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #128
2025-12-23 12:56:07 +00:00
Arseni
ec54579921 Implemented cooldown before User is able to resend confirmation code for 2fa 2025-12-23 14:56:47 +03:00
1ed76f7243 Merge pull request 'Fixed tokens revocation' (#127) from devid-122 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #127
2025-12-22 20:25:03 +00:00
Stephan D
6527d183ec Fixed tokens revokation 2025-12-22 21:22:51 +01:00
41b0dec460 Merge pull request 'Fixes for Settings Page' (#123) from SEND011 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #123
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-22 19:26:44 +00:00
Arseni
d26ba84094 Moved the AccountName widgets to pull providers from context 2025-12-22 21:38:26 +03:00
Arseni
4073c8819c Fixed imports 2025-12-22 21:12:21 +03:00
Arseni
47ada0691c Fixes for Settings Page 2025-12-22 21:09:58 +03:00
97c67670e5 Merge pull request 'added fix for active indexed tokens + improved data structure for wallet description' (#122) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #122
2025-12-22 17:30:48 +00:00
Stephan D
dfad7fb335 added fix for active indexed tokens + improved data structure for wallet description 2025-12-22 18:30:15 +01:00
41abf723e6 Merge pull request 'multiquote service' (#117) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #117
2025-12-17 20:56:28 +00:00
Stephan D
2d6586430f multiquote service 2025-12-17 21:56:07 +01:00
86 changed files with 1966 additions and 479 deletions

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -49,6 +49,6 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/protobuf v1.36.11
)

View File

@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -49,7 +49,7 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -13,7 +13,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -212,10 +212,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -34,16 +34,18 @@ messaging:
reconnect_wait: 5
chains:
- name: arbitrum_one
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
- name: tron
chain_id: 728126428
native_token: TRX
rpc_url_env: CHAIN_GATEWAY_RPC_URL
tokens:
- symbol: USDC
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
- symbol: USDT
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
- symbol: USDC
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe"
service_wallet:
chain: arbitrum_one
chain: tron
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY

View File

@@ -15,14 +15,14 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222215617-2e6965a531ff // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
@@ -86,5 +86,5 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222215617-2e6965a531ff h1:dkcn0B/pE1RTOeW9MB/fCxpKq0QaeWE6LkCmzURNE4g=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222215617-2e6965a531ff/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -362,10 +362,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
@@ -95,7 +96,31 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
}
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey,
WalletRef: walletRef,
OrganizationRef: organizationRef,
@@ -106,7 +131,10 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive,
Metadata: shared.CloneMetadata(req.GetMetadata()),
Metadata: metadata,
}
if description != nil {
wallet.Describable.Description = description
}
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

@@ -3,6 +3,7 @@ package wallet
import (
"context"
"math/big"
"net/url"
"strings"
"time"
@@ -14,24 +15,53 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
logger := deps.Logger
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
network := deps.Networks[networkKey]
rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
zap.String("wallet_address", strings.ToLower(strings.TrimSpace(wallet.DepositAddress))),
zap.String("rpc_endpoint", safeRPCLabel(rpcURL)),
}
if rpcURL == "" {
if logger != nil {
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) {
if logger != nil {
logger.Warn("invalid contract address for balance fetch", logFields...)
}
return nil, merrors.InvalidArgument("invalid contract address")
}
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
if logger != nil {
logger.Warn("invalid wallet address for balance fetch", logFields...)
}
return nil, merrors.InvalidArgument("invalid wallet address")
}
if logger != nil {
logger.Info("fetching on-chain wallet balance", logFields...)
}
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
if logger != nil {
logger.Warn("failed to connect rpc", append(logFields, zap.Error(err))...)
}
return nil, merrors.Internal("failed to connect rpc: " + err.Error())
}
defer client.Close()
@@ -41,22 +71,46 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil {
if logger != nil {
logger.Warn("failed to parse erc20 abi", append(logFields, zap.Error(err))...)
}
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
}
tokenAddr := common.HexToAddress(contract)
walletAddr := common.HexToAddress(wallet.DepositAddress)
if logger != nil {
logger.Debug("calling token decimals", logFields...)
}
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil {
if logger != nil {
logger.Warn("token decimals call failed", append(logFields, zap.Error(err))...)
}
return nil, err
}
if logger != nil {
logger.Debug("calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
}
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
if err != nil {
if logger != 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))
if logger != nil {
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
}
@@ -122,3 +176,14 @@ const erc20ABIJSON = `
"type": "function"
}
]`
func safeRPCLabel(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" {
return ""
}
parsed.User = nil
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}

View File

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

View File

@@ -48,28 +48,28 @@ type onChainExecutor struct {
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
if o.keyManager == nil {
o.logger.Error("key manager not configured")
o.logger.Warn("key manager not configured")
return "", executorInternal("key manager is not configured", nil)
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
o.logger.Error("network rpc url missing", zap.String("network", network.Name))
o.logger.Warn("network rpc url missing", zap.String("network", network.Name))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
o.logger.Error("transfer context missing")
o.logger.Warn("transfer context missing")
return "", executorInvalid("transfer context missing")
}
if strings.TrimSpace(source.KeyReference) == "" {
o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference")
}
if strings.TrimSpace(source.DepositAddress) == "" {
o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing deposit address")
}
if !common.IsHexAddress(destinationAddress) {
o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
return "", executorInvalid("invalid destination address " + destinationAddress)
}

View File

@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
defer cancel()
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
}
}(transferRef, sourceWalletRef, network)
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
@@ -19,7 +20,8 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"`
storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -77,6 +79,15 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))

View File

@@ -11,7 +11,7 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -214,10 +214,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -51,5 +51,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -214,10 +214,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -52,7 +52,7 @@ require (
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -227,10 +227,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -24,7 +24,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -62,5 +62,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
)

View File

@@ -215,10 +215,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
)
@@ -93,6 +93,6 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -267,12 +267,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
import "google/protobuf/timestamp.proto";
import "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto";
import "common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets.
enum ChainNetwork {
@@ -57,6 +58,7 @@ message ManagedWallet {
map<string, string> metadata = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
}
message CreateManagedWalletRequest {
@@ -65,6 +67,7 @@ message CreateManagedWalletRequest {
string owner_ref = 3;
Asset asset = 4;
map<string, string> metadata = 5;
common.describable.v1.Describable describable = 6;
}
message CreateManagedWalletResponse {

View File

@@ -139,6 +139,6 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
)

View File

@@ -359,12 +359,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,6 +2,7 @@ package sresponse
import (
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
@@ -26,6 +27,8 @@ type wallet struct {
DepositAddress string `json:"depositAddress"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
@@ -80,6 +83,27 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
token = asset.GetTokenSymbol()
contract = asset.GetContractAddress()
}
name := ""
if d := w.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(w.GetMetadata()["name"])
}
if name == "" {
name = w.GetWalletRef()
}
var description *string
if d := w.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
description = &trimmed
}
}
return wallet{
WalletRef: w.GetWalletRef(),
OrganizationRef: w.GetOrganizationRef(),
@@ -92,6 +116,8 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
DepositAddress: w.GetDepositAddress(),
Status: w.GetStatus().String(),
Metadata: w.GetMetadata(),
Name: name,
Description: description,
CreatedAt: tsToString(w.GetCreatedAt()),
UpdatedAt: tsToString(w.GetUpdatedAt()),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
);
}

View 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(),
);
}

View File

@@ -24,8 +24,10 @@ extension WalletDTOMapper on WalletDTO {
balance: balance?.toDomain(),
availableMoney: balance?.available?.toDomain(),
describable: newDescribable(
name: metadata?['name'] ?? 'Crypto Wallet',
description: metadata?['description'],
name: name.isNotEmpty ? name : (metadata?['name'] ?? 'Crypto Wallet'),
description: (description != null && description!.isNotEmpty)
? description
: metadata?['description'],
),
);
}

View File

@@ -11,6 +11,8 @@ class PendingLogin {
final String destination;
final int ttlSeconds;
final SessionIdentifier session;
final int? cooldownSeconds;
final DateTime? cooldownUntil;
const PendingLogin({
required this.account,
@@ -18,6 +20,8 @@ class PendingLogin {
required this.destination,
required this.ttlSeconds,
required this.session,
this.cooldownSeconds,
this.cooldownUntil,
});
factory PendingLogin.fromResponse(
@@ -30,4 +34,30 @@ class PendingLogin {
ttlSeconds: response.ttlSeconds,
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;
}
}

View 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,
});
}

View 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,
});
}

View File

@@ -10,6 +10,7 @@ import 'package:pshared/api/requests/signup.dart';
import 'package:pshared/api/requests/login_data.dart';
import 'package:pshared/config/constants.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/pending_login.dart';
import 'package:pshared/models/describable.dart';
@@ -101,8 +102,8 @@ class AccountProvider extends ChangeNotifier {
if (pending == null) {
throw Exception('Pending login data is missing');
}
await VerificationService.requestLoginCode(pending);
_pendingLogin = pending;
final confirmation = await VerificationService.requestLoginCode(pending);
_pendingLogin = _applyConfirmationMeta(pending, confirmation);
_authState = AuthState.idle;
_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) {
_pendingLogin = null;
_authState = AuthState.ready;
@@ -228,6 +250,13 @@ class AccountProvider extends ChangeNotifier {
}
}
Future<Account?> resetUsername(String userName) async {
if (account == null) throw ErrorUnauthorized();
return update(
describable: account!.describable.copyWith(name: userName),
);
}
Future<void> forgotPassword(String email) async {
_setResource(_resource.copyWith(isLoading: true, error: null));
try {

View File

@@ -1,4 +1,5 @@
import 'package:logging/logging.dart';
import 'package:pshared/service/device_id.dart';
import 'package:share_plus/share_plus.dart';
@@ -66,7 +67,11 @@ class AccountService {
return _getAccount(AuthorizationService.getPATCHResponse(
_objectType,
'password',
ChangePassword(oldPassword: oldPassword, newPassword: newPassword).toJson(),
ChangePassword(
oldPassword: oldPassword,
newPassword: newPassword,
deviceId: await DeviceIdManager.getDeviceId(),
).toJson(),
));
}

View File

@@ -1,9 +1,13 @@
import 'package:logging/logging.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/quotes.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/quotes.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
@@ -21,4 +25,14 @@ class QuotationService {
);
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();
}
}

View File

@@ -5,6 +5,7 @@ import 'package:pshared/api/responses/login.dart';
import 'package:pshared/data/mapper/session_identifier.dart';
import 'package:pshared/models/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/service/authorization/storage.dart';
import 'package:pshared/service/services.dart';
@@ -15,24 +16,26 @@ class VerificationService {
static final _logger = Logger('service.verification');
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');
await getPOSTResponse(
final response = await getPOSTResponse(
_objectType,
'',
LoginConfirmationRequest(destination: destination).toJson(),
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');
await getPOSTResponse(
final response = await getPOSTResponse(
_objectType,
'/resend',
LoginConfirmationRequest(destination: destination).toJson(),
authToken: pending.pendingToken.token,
);
return ConfirmationResponse.fromJson(response);
}
static Future<Account> confirmLoginCode({

View File

@@ -0,0 +1,45 @@
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,
});
final TextEditingController controller;
final double fieldWidth;
final bool isEnabled;
final String confirmPasswordLabel;
final TextEditingController newPasswordController;
final String missingPasswordError;
final String passwordsDoNotMatchError;
@override
Widget build(BuildContext context) {
return SizedBox(
width: fieldWidth,
child: TextFormField(
controller: controller,
obscureText: true,
enabled: isEnabled,
decoration: InputDecoration(
labelText: confirmPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return missingPasswordError;
if (value != newPasswordController.text) {
return passwordsDoNotMatchError;
}
return null;
},
),
);
}
}

View File

@@ -0,0 +1,35 @@
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.validator,
});
final TextEditingController controller;
final String labelText;
final double fieldWidth;
final bool isEnabled;
final String? Function(String?) validator;
@override
Widget build(BuildContext context) {
return SizedBox(
width: fieldWidth,
child: TextFormField(
controller: controller,
obscureText: true,
enabled: isEnabled,
decoration: InputDecoration(
labelText: labelText,
border: const OutlineInputBorder(),
),
validator: validator,
),
);
}
}

View File

@@ -0,0 +1,69 @@
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,
});
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;
@override
Widget build(BuildContext context) {
return Column(
children: [
PasswordField(
controller: oldPasswordController,
labelText: oldPasswordLabel,
fieldWidth: fieldWidth,
isEnabled: isEnabled,
validator: (value) =>
(value == null || value.isEmpty) ? missingPasswordError : null,
),
SizedBox(height: gapSmall),
PasswordField(
controller: newPasswordController,
labelText: newPasswordLabel,
fieldWidth: fieldWidth,
isEnabled: isEnabled,
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,
),
],
);
}
}

View File

@@ -1,8 +1,6 @@
import 'package:pshared/models/wallet/wallet.dart' as domain;
import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pweb/models/wallet.dart';
@@ -26,10 +24,7 @@ extension WalletUiMapper on domain.WalletModel {
network: asset.chain,
tokenSymbol: asset.tokenSymbol,
contractAddress: asset.contractAddress,
describable: newDescribable(
name: metadata?['name'] ?? 'Crypto Wallet',
description: metadata?['description'],
),
describable: describable,
);
}
}

View File

@@ -9,7 +9,13 @@
"usernameErrorInvalid": "Provide a valid email address",
"usernameUnknownTLD": "Domain .{domain} is not known, please, check it",
"password": "Password",
"oldPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"changePassword": "Change password",
"savePassword": "Save changed password",
"changePasswordSuccess": "Password updated",
"changePasswordError": "Could not update password",
"passwordValidationRuleDigit": "has digit",
"passwordValidationRuleUpperCase": "has uppercase letter",
"passwordValidationRuleLowerCase": "has lowercase letter",

View File

@@ -9,7 +9,13 @@
"usernameErrorInvalid": "Укажите действительный адрес электронной почты",
"usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его",
"password": "Пароль",
"oldPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите пароль",
"changePassword": "Изменить пароль",
"savePassword": "Сохранить пароль",
"changePasswordSuccess": "Пароль обновлен",
"changePasswordError": "Не удалось обновить пароль",
"passwordValidationRuleDigit": "содержит цифру",
"passwordValidationRuleUpperCase": "содержит заглавную букву",
"passwordValidationRuleLowerCase": "содержит строчную букву",

View File

@@ -0,0 +1 @@
enum EditState { view, edit, saving }

View File

@@ -13,9 +13,15 @@ class ResendCodeButton extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final localizations = AppLocalizations.of(context)!;
final provider = context.watch<TwoFactorProvider>();
final isDisabled = provider.isCooldownActive || provider.isResending;
final label = provider.isCooldownActive
? '${localizations.twoFactorResend} (${_formatCooldown(provider.cooldownRemainingSeconds)})'
: localizations.twoFactorResend;
return TextButton(
onPressed: () => context.read<TwoFactorProvider>().resendCode(),
onPressed: isDisabled ? null : () => provider.resendCode(),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
@@ -26,7 +32,25 @@ class ResendCodeButton extends StatelessWidget {
decoration: TextDecoration.underline,
),
),
child: Text(localizations.twoFactorResend),
child: provider.isResending
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.primary,
),
)
: Text(label),
);
}
String _formatCooldown(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
if (minutes > 0) {
return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}';
}
return remainingSeconds.toString();
}
}

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
//import 'package:provider/provider.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/widgets/drawer/avatar.dart';
class AvatarTile extends StatefulWidget {
@@ -28,80 +31,106 @@ class _AvatarTileState extends State<AvatarTile> {
static const double _avatarSize = 96.0;
static const double _iconSize = 32.0;
static const double _titleSpacing = 4.0;
static const String _placeholderAsset = 'assets/images/avatar_placeholder.png';
bool _isHovering = false;
bool _isUploading = false;
String _errorText = '';
Future<void> _pickImage(AccountProvider provider) async {
if (_isUploading) return;
Future<void> _pickImage() async {
final picker = ImagePicker();
final file = await picker.pickImage(source: ImageSource.gallery);
if (file != null) {
debugPrint('Selected new avatar: ${file.path}');
if (file == null) return;
setState(() {
_isUploading = true;
_errorText = '';
});
try {
await provider.uploadAvatar(file);
} catch (_) {
if (!mounted) return;
setState(() => _errorText = widget.errorText);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(widget.errorText)),
);
} finally {
if (mounted) {
setState(() => _isUploading = false);
}
}
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final safeUrl =
widget.avatarUrl?.trim().isNotEmpty == true ? widget.avatarUrl : null;
final theme = Theme.of(context);
return Consumer<AccountProvider>(
builder: (context, provider, _) {
final theme = Theme.of(context);
final isBusy = _isUploading || provider.isLoading;
return Column(
children: [
MouseRegion(
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector(
onTap: _pickImage,
child: Stack(
alignment: Alignment.center,
children: [
ClipOval(
child: safeUrl != null
? Image.network(
safeUrl,
return Column(
children: [
MouseRegion(
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector(
onTap: isBusy ? null : () => _pickImage(provider),
child: Stack(
alignment: Alignment.center,
children: [
AccountAvatar(
size: _avatarSize,
showHeader: false,
provider: provider,
fallbackUrl: widget.avatarUrl,
),
if (_isHovering || _isUploading)
ClipOval(
child: Container(
width: _avatarSize,
height: _avatarSize,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(),
)
: _buildPlaceholder(),
),
if (_isHovering)
ClipOval(
child: Container(
width: _avatarSize,
height: _avatarSize,
color: theme.colorScheme.primary.withAlpha(90),
child: Icon(
Icons.camera_alt,
color: theme.colorScheme.onSecondary,
size: _iconSize,
color: theme.colorScheme.primary.withAlpha(90),
child: _isUploading
? SizedBox(
width: _iconSize,
height: _iconSize,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(theme.colorScheme.onSecondary),
),
)
: Icon(
Icons.camera_alt,
color: theme.colorScheme.onSecondary,
size: _iconSize,
),
),
),
),
),
],
],
),
),
),
),
),
SizedBox(height: _titleSpacing),
Text(
loc.avatarHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondary,
),
),
],
);
}
Widget _buildPlaceholder() {
return Image.asset(
_placeholderAsset,
width: _avatarSize,
height: _avatarSize,
fit: BoxFit.cover,
SizedBox(height: _titleSpacing),
Text(
widget.description,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondary,
),
),
if (_errorText.isNotEmpty) ...[
SizedBox(height: _titleSpacing),
Text(
_errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
);
},
);
}
}

View File

@@ -1,127 +0,0 @@
import 'package:flutter/material.dart';
class AccountName extends StatefulWidget {
final String name;
final String title;
final String hintText;
final String errorText;
const AccountName({
super.key,
required this.name,
required this.title,
required this.hintText,
required this.errorText,
});
@override
State<AccountName> createState() => _AccountNameState();
}
class _AccountNameState extends State<AccountName> {
static const double _inputWidth = 200;
static const double _spacing = 8;
static const double _errorSpacing = 4;
static const double _borderWidth = 2;
late final TextEditingController _controller;
bool _isEditing = false;
late String _originalName;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.name);
_originalName = widget.name;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _startEditing() => setState(() => _isEditing = true);
void _cancelEditing() {
setState(() {
_controller.text = _originalName;
_isEditing = false;
});
}
void _saveEditing() {
setState(() {
_originalName = _controller.text;
_isEditing = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isEditing)
SizedBox(
width: _inputWidth,
child: TextFormField(
controller: _controller,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
autofocus: true,
decoration: InputDecoration(
hintText: widget.hintText,
isDense: true,
border: UnderlineInputBorder(
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: _borderWidth,
),
),
),
),
)
else
Text(
_originalName,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: _spacing),
if (_isEditing) ...[
IconButton(
icon: Icon(Icons.check, color: theme.colorScheme.primary),
onPressed: _saveEditing,
),
IconButton(
icon: Icon(Icons.close, color: theme.colorScheme.error),
onPressed: _cancelEditing,
),
] else
IconButton(
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
onPressed: _startEditing,
),
],
),
const SizedBox(height: _errorSpacing),
if (widget.errorText.isEmpty)
Text(
widget.errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/providers/account_name.dart';
class AccountNameActions extends StatelessWidget {
const AccountNameActions({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch<AccountNameState>();
final theme = Theme.of(context);
if (state.isEditing) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.check, color: theme.colorScheme.primary),
onPressed: state.isBusy
? null
: () async {
final wasSaved = await state.save();
if (!context.mounted || wasSaved || state.errorText.isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorText)),
);
},
),
IconButton(
icon: Icon(Icons.close, color: theme.colorScheme.error),
onPressed: state.isBusy ? null : state.cancelEditing,
),
],
);
}
return IconButton(
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
onPressed: state.isBusy ? null : state.startEditing,
);
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/pages/settings/profile/account/name/actions.dart';
import 'package:pweb/providers/account_name.dart';
import 'package:pweb/pages/settings/profile/account/name/text.dart';
class _AccountNameConstants {
static const inputWidth = 200.0;
static const spacing = 8.0;
static const errorSpacing = 4.0;
static const borderWidth = 2.0;
}
class AccountName extends StatelessWidget {
final String name;
final String title;
final String hintText;
final String errorText;
const AccountName({
super.key,
required this.name,
required this.title,
required this.hintText,
required this.errorText,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (ctx) => AccountNameState(
initialName: name,
errorMessage: errorText,
accountProvider: ctx.read<AccountProvider>(),
),
child: _AccountNameBody(
hintText: hintText,
),
);
}
}
class _AccountNameBody extends StatelessWidget {
const _AccountNameBody({
required this.hintText,
});
final String hintText;
@override
Widget build(BuildContext context) {
final state = context.watch<AccountNameState>();
final provider = context.watch<AccountProvider>();
final theme = Theme.of(context);
final currentName = provider.account?.name ?? state.initialName;
state.syncName(currentName);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AccountNameText(
hintText: hintText,
inputWidth: _AccountNameConstants.inputWidth,
borderWidth: _AccountNameConstants.borderWidth,
),
const SizedBox(width: _AccountNameConstants.spacing),
const AccountNameActions(),
],
),
const SizedBox(height: _AccountNameConstants.errorSpacing),
if (state.errorText.isNotEmpty)
Text(
state.errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/providers/account_name.dart';
class AccountNameText extends StatelessWidget {
const AccountNameText({
super.key,
required this.hintText,
required this.inputWidth,
required this.borderWidth,
});
final String hintText;
final double inputWidth;
final double borderWidth;
@override
Widget build(BuildContext context) {
final state = context.watch<AccountNameState>();
final theme = Theme.of(context);
if (state.isEditing) {
return SizedBox(
width: inputWidth,
child: TextFormField(
controller: state.controller,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
autofocus: true,
enabled: !state.isBusy,
decoration: InputDecoration(
hintText: hintText,
isDense: true,
border: UnderlineInputBorder(
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: borderWidth,
),
),
),
),
);
}
return Text(
state.currentName,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/pages/settings/profile/account/password/form/form.dart';
import 'package:pweb/providers/password_form.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountPasswordContent extends StatelessWidget {
const AccountPasswordContent({
required this.title,
required this.successText,
required this.errorText,
required this.oldPasswordLabel,
required this.newPasswordLabel,
required this.confirmPasswordLabel,
required this.savePassword,
required this.loc,
});
final String title;
final String successText;
final String errorText;
final String oldPasswordLabel;
final String newPasswordLabel;
final String confirmPasswordLabel;
final String savePassword;
final AppLocalizations loc;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Consumer2<AccountProvider, PasswordFormProvider>(
builder: (context, accountProvider, formProvider, _) {
final isBusy = accountProvider.isLoading || formProvider.isSaving;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextButton.icon(
onPressed: isBusy ? null : formProvider.toggleExpanded,
icon: Icon(Icons.lock_outline, color: theme.colorScheme.primary),
label: Text(title, style: theme.textTheme.bodyMedium),
),
if (formProvider.isExpanded)
PasswordForm(
formProvider: formProvider,
accountProvider: accountProvider,
isBusy: accountProvider.isLoading,
oldPasswordLabel: oldPasswordLabel,
newPasswordLabel: newPasswordLabel,
confirmPasswordLabel: confirmPasswordLabel,
savePassword: savePassword,
successText: successText,
errorText: errorText,
loc: loc,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class PasswordErrorText extends StatelessWidget {
const PasswordErrorText({
super.key,
required this.errorText,
required this.gapSmall,
});
final String errorText;
final double gapSmall;
@override
Widget build(BuildContext context) {
if (errorText.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Column(
children: [
SizedBox(height: gapSmall),
Text(
errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/widgets/password/fields.dart';
import 'package:pshared/utils/snackbar.dart';
import 'package:pweb/providers/password_form.dart';
import 'package:pweb/pages/settings/profile/account/password/form/error_text.dart';
import 'package:pweb/pages/settings/profile/account/password/form/submit_button.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PasswordForm extends StatelessWidget {
const PasswordForm({
super.key,
required this.formProvider,
required this.accountProvider,
required this.isBusy,
required this.oldPasswordLabel,
required this.newPasswordLabel,
required this.confirmPasswordLabel,
required this.savePassword,
required this.successText,
required this.errorText,
required this.loc,
});
static const double _fieldWidth = 320;
static const double _gapMedium = 12;
static const double _gapSmall = 8;
final PasswordFormProvider formProvider;
final AccountProvider accountProvider;
final bool isBusy;
final String oldPasswordLabel;
final String newPasswordLabel;
final String confirmPasswordLabel;
final String savePassword;
final String successText;
final String errorText;
final AppLocalizations loc;
@override
Widget build(BuildContext context) {
final isFormBusy = isBusy || formProvider.isSaving;
return Column(
children: [
const SizedBox(height: _gapMedium),
Form(
key: formProvider.formKey,
child: Column(
children: [
PasswordFields(
oldPasswordController: formProvider.oldPasswordController,
newPasswordController: formProvider.newPasswordController,
confirmPasswordController: formProvider.confirmPasswordController,
oldPasswordLabel: oldPasswordLabel,
newPasswordLabel: newPasswordLabel,
confirmPasswordLabel: confirmPasswordLabel,
missingPasswordError: loc.errorPasswordMissing,
passwordsDoNotMatchError: loc.passwordsDoNotMatch,
fieldWidth: _fieldWidth,
gapSmall: _gapSmall,
isEnabled: !isFormBusy,
),
const SizedBox(height: _gapMedium),
PasswordSubmitButton(
isBusy: isFormBusy,
label: savePassword,
onSubmit: () async {
try {
await formProvider.submit(
accountProvider: accountProvider,
errorText: errorText,
);
if (!context.mounted) return;
notifyUser(context, successText);
} catch (e) {
if (!context.mounted) return;
await postNotifyUserOfErrorX(
context: context,
errorSituation: errorText,
exception: e,
);
}
},
),
PasswordErrorText(
errorText: formProvider.errorText,
gapSmall: _gapSmall,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class PasswordSubmitButton extends StatelessWidget {
const PasswordSubmitButton({
super.key,
required this.isBusy,
required this.onSubmit,
required this.label,
});
final bool isBusy;
final VoidCallback onSubmit;
final String label;
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
onPressed: isBusy ? null : onSubmit,
icon: const Icon(Icons.save_outlined),
label: Text(label),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/pages/settings/profile/account/password/content.dart';
import 'package:pweb/providers/password_form.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountPassword extends StatelessWidget {
final String title;
final String successText;
final String errorText;
final String oldPasswordLabel;
final String newPasswordLabel;
final String confirmPasswordLabel;
final String savePassword;
const AccountPassword({
super.key,
required this.title,
required this.successText,
required this.errorText,
required this.oldPasswordLabel,
required this.newPasswordLabel,
required this.confirmPasswordLabel,
required this.savePassword,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return ChangeNotifierProvider(
create: (_) => PasswordFormProvider(),
child: AccountPasswordContent(
title: title,
successText: successText,
errorText: errorText,
oldPasswordLabel: oldPasswordLabel,
newPasswordLabel: newPasswordLabel,
confirmPasswordLabel: confirmPasswordLabel,
savePassword: savePassword,
loc: loc,
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class PasswordToggleButton extends StatelessWidget {
const PasswordToggleButton({
super.key,
required this.title,
required this.isExpanded,
required this.isBusy,
required this.onToggle,
});
final String title;
final bool isExpanded;
final bool isBusy;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = theme.colorScheme.primary;
return TextButton.icon(
onPressed: isBusy
? null
: () {
onToggle();
},
icon: Icon(
isExpanded ? Icons.lock_open : Icons.lock_outline,
color: iconColor,
),
label: Text(title, style: theme.textTheme.bodyMedium),
);
}
}

View File

@@ -1,8 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/pages/settings/profile/account/avatar.dart';
import 'package:pweb/pages/settings/profile/account/locale.dart';
import 'package:pweb/pages/settings/profile/account/name.dart';
import 'package:pweb/pages/settings/profile/account/name/name.dart';
import 'package:pweb/pages/settings/profile/account/password/password.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -18,34 +23,51 @@ class ProfileSettingsPage extends StatelessWidget {
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
final accountAvatarUrl = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl,
);
return Material(
elevation: 4,
borderRadius: BorderRadius.circular(_cardRadius),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.onSecondary,
child: Padding(
padding: _cardPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: _itemSpacing,
children: [
AvatarTile(
avatarUrl: 'https://avatars.githubusercontent.com/u/65651201',
title: loc.avatar,
description: loc.avatarHint,
errorText: loc.avatarUpdateError,
),
AccountName(
name: loc.userNamePlaceholder,
title: loc.accountName,
hintText: loc.accountNameHint,
errorText: loc.accountNameUpdateError,
),
LocalePicker(
title: loc.language,
),
],
return Align(
alignment: Alignment.topCenter,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(_cardRadius),
color: theme.colorScheme.onSecondary,
child: Padding(
padding: _cardPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: _itemSpacing,
children: [
AvatarTile(
avatarUrl: accountAvatarUrl,
title: loc.avatar,
description: loc.avatarHint,
errorText: loc.avatarUpdateError,
),
AccountName(
name: accountName ?? loc.userNamePlaceholder,
title: loc.accountName,
hintText: loc.accountNameHint,
errorText: loc.accountNameUpdateError,
),
AccountPassword(
title: loc.changePassword,
successText: loc.changePasswordSuccess,
errorText: loc.changePasswordError,
oldPasswordLabel: loc.oldPassword,
newPasswordLabel: loc.newPassword,
confirmPasswordLabel: loc.confirmPassword,
savePassword: loc.savePassword,
),
LocalePicker(
title: loc.language,
),
],
),
),
),
);

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pweb/utils/error/snackbar.dart';
enum _EditState { view, edit, saving }
import 'package:pweb/generated/i18n/app_localizations.dart';
/// Базовый класс, управляющий состояниями (view/edit/saving),
/// показом snackbar ошибок и успешного сохранения.
abstract class BaseEditTile<T> extends AbstractSettingsTile {
const BaseEditTile({
super.key,
@@ -16,6 +17,7 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
required this.valueGetter,
required this.valueSetter,
required this.errorSituation,
this.editStateNotifier,
});
final IconData icon;
@@ -23,12 +25,10 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
final ValueGetter<T?> valueGetter;
final Future<void> Function(T) valueSetter;
final String errorSituation;
final ValueNotifier<EditState>? editStateNotifier;
/// Рисует в режиме просмотра (read-only).
Widget buildView(BuildContext context, T? value);
/// Рисует UI редактора.
/// Если [useDialogEditor]==true, его обернут в диалог.
Widget buildEditor(
BuildContext context,
T? initial,
@@ -37,7 +37,6 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
bool isSaving,
);
/// true → показывать редактор в диалоге, false → inline под заголовком.
bool get useDialogEditor => false;
@override
@@ -52,16 +51,35 @@ class _BaseEditTileBody<T> extends StatefulWidget {
}
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
_EditState _state = _EditState.view;
bool get _isSaving => _state == _EditState.saving;
late final ValueNotifier<EditState> _stateNotifier;
late final bool _ownsNotifier;
bool get _isSaving => _stateNotifier.value == EditState.saving;
@override
void initState() {
super.initState();
final providedNotifier = widget.delegate.editStateNotifier ??
Provider.of<ValueNotifier<EditState>?>(context, listen: false);
_ownsNotifier = providedNotifier == null;
_stateNotifier = providedNotifier ?? ValueNotifier(EditState.view);
}
@override
void dispose() {
if (_ownsNotifier) {
_stateNotifier.dispose();
}
super.dispose();
}
Future<void> _performSave(T newValue) async {
final current = widget.delegate.valueGetter();
if (newValue == current) {
setState(() => _state = _EditState.view);
_stateNotifier.value = EditState.view;
return;
}
setState(() => _state = _EditState.saving);
_stateNotifier.value = EditState.saving;
final sms = ScaffoldMessenger.of(context);
final locs = AppLocalizations.of(context)!;
try {
@@ -78,7 +96,7 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
exception: e,
);
} finally {
if (mounted) setState(() => _state = _EditState.view);
if (mounted) _stateNotifier.value = EditState.view;
}
}
@@ -110,33 +128,45 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
@override
Widget build(BuildContext context) {
final delegate = widget.delegate;
final current = delegate.valueGetter();
// Диалоговый режим
if (delegate.useDialogEditor) {
return SettingsTile.navigation(
leading: Icon(delegate.icon),
title: Text(delegate.title),
value: delegate.buildView(context, current),
onPressed: (_) => _openDialogEditor(),
return ValueListenableBuilder<EditState>(
valueListenable: _stateNotifier,
builder: (context, state, _) {
final current = delegate.valueGetter();
return SettingsTile.navigation(
leading: Icon(delegate.icon),
title: Text(delegate.title),
value: delegate.buildView(context, current),
onPressed: state == EditState.saving ? null : (_) => _openDialogEditor(),
);
},
);
}
// Inline-режим (под заголовком будет редактор прямо в tile)
return SettingsTile.navigation(
leading: Icon(delegate.icon),
title: Text(delegate.title),
value: _state == _EditState.view
? delegate.buildView(context, current)
: delegate.buildEditor(
context,
current,
_performSave,
() => setState(() => _state = _EditState.view),
_isSaving,
),
onPressed: (_) {
if (_state == _EditState.view) setState(() => _state = _EditState.edit);
return ValueListenableBuilder<EditState>(
valueListenable: _stateNotifier,
builder: (context, state, _) {
final current = delegate.valueGetter();
final isView = state == EditState.view;
final isSaving = state == EditState.saving;
return SettingsTile.navigation(
leading: Icon(delegate.icon),
title: Text(delegate.title),
value: isView
? delegate.buildView(context, current)
: delegate.buildEditor(
context,
current,
_performSave,
() => _stateNotifier.value = EditState.view,
isSaving,
),
onPressed: (_) {
if (isView) _stateNotifier.value = EditState.edit;
},
);
},
);
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/models/edit_state.dart';
class AccountNameState extends ChangeNotifier {
AccountNameState({
required this.initialName,
required this.errorMessage,
required AccountProvider accountProvider,
}) : _accountProvider = accountProvider {
_controller = TextEditingController(text: initialName);
}
final AccountProvider _accountProvider;
final String initialName;
final String errorMessage;
late final TextEditingController _controller;
EditState _editState = EditState.view;
String _errorText = '';
bool _disposed = false;
TextEditingController get controller => _controller;
EditState get editState => _editState;
String get errorText => _errorText;
bool get isEditing => _editState != EditState.view;
bool get isSaving => _editState == EditState.saving;
bool get isBusy => _accountProvider.isLoading || isSaving;
String get currentName => _accountProvider.account?.name ?? initialName;
void startEditing() => _setState(EditState.edit);
void cancelEditing() {
_controller.text = currentName;
_setError('');
_setState(EditState.view);
}
void syncName(String latestName) {
if (isEditing) return;
if (_controller.text != latestName) {
_controller.text = latestName;
}
}
Future<bool> save() async {
final newName = _controller.text.trim();
final current = currentName;
if (newName.isEmpty || newName == current) {
cancelEditing();
return false;
}
_setError('');
_setState(EditState.saving);
try {
await _accountProvider.resetUsername(newName);
_setState(EditState.view);
return true;
} catch (_) {
_setError(errorMessage);
_setState(EditState.edit);
return false;
} finally {
if (_editState == EditState.saving) {
_setState(EditState.edit);
}
}
}
void _setState(EditState value) {
if (_disposed || _editState == value) return;
_editState = value;
notifyListeners();
}
void _setError(String value) {
if (_disposed) return;
_errorText = value;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_controller.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/models/edit_state.dart';
class PasswordFormProvider extends ChangeNotifier {
final formKey = GlobalKey<FormState>();
final oldPasswordController = TextEditingController();
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
EditState _state = EditState.view;
String _errorText = '';
bool _disposed = false;
bool get isExpanded => _state != EditState.view;
bool get isSaving => _state == EditState.saving;
String get errorText => _errorText;
EditState get state => _state;
void toggleExpanded() {
if (_state == EditState.saving) return;
_setState(_state == EditState.view ? EditState.edit : EditState.view);
_setError('');
}
Future<void> submit({
required AccountProvider accountProvider,
required String errorText,
}) async {
final currentForm = formKey.currentState;
if (currentForm == null || !currentForm.validate()) return;
_setState(EditState.saving);
_setError('');
try {
await accountProvider.changePassword(
oldPasswordController.text,
newPasswordController.text,
);
oldPasswordController.clear();
newPasswordController.clear();
confirmPasswordController.clear();
} catch (e) {
_setError(errorText);
rethrow;
} finally {
_setState(EditState.edit);
}
}
void _setState(EditState value) {
if (_state == value || _disposed) return;
_state = value;
notifyListeners();
}
void _setError(String value) {
if (_disposed) return;
_errorText = value;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
oldPasswordController.dispose();
newPasswordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
@@ -14,15 +16,21 @@ class TwoFactorProvider extends ChangeNotifier {
TwoFactorProvider();
bool _isSubmitting = false;
bool _isResending = false;
bool _hasError = false;
bool _verificationSuccess = false;
String? _errorMessage;
String? _currentPendingToken;
Timer? _cooldownTimer;
int _cooldownRemainingSeconds = 0;
bool get isSubmitting => _isSubmitting;
bool get isResending => _isResending;
bool get hasError => _hasError;
bool get verificationSuccess => _verificationSuccess;
String? get errorMessage => _errorMessage;
int get cooldownRemainingSeconds => _cooldownRemainingSeconds;
bool get isCooldownActive => _cooldownRemainingSeconds > 0;
PendingLogin? get pendingLogin => _accountProvider.pendingLogin;
void update(AccountProvider accountProvider) {
@@ -33,6 +41,7 @@ class TwoFactorProvider extends ChangeNotifier {
_resetState();
_currentPendingToken = token;
}
_syncCooldown(pending);
}
Future<void> submitCode(String code) async {
@@ -70,12 +79,23 @@ class TwoFactorProvider extends ChangeNotifier {
_logger.warning('No pending login to resend code for');
return;
}
if (_isResending || isCooldownActive) return;
_isResending = true;
_hasError = false;
_errorMessage = null;
notifyListeners();
try {
await VerificationService.resendLoginCode(pending);
final confirmation = await VerificationService.resendLoginCode(pending);
_accountProvider.updatePendingLogin(confirmation);
_startCooldown(confirmation.cooldownSeconds);
} catch (e) {
_logger.warning('Failed to resend login code', e);
_hasError = true;
_errorMessage = e.toString();
} finally {
_isResending = false;
notifyListeners();
}
}
@@ -87,9 +107,71 @@ class TwoFactorProvider extends ChangeNotifier {
void _resetState() {
_isSubmitting = false;
_isResending = false;
_hasError = false;
_errorMessage = null;
_verificationSuccess = false;
_stopCooldown();
notifyListeners();
}
void _syncCooldown(PendingLogin? pending) {
if (pending == null) {
_stopCooldown(notify: _cooldownRemainingSeconds != 0);
return;
}
final remaining = pending.cooldownRemainingSeconds;
if (remaining <= 0) {
_stopCooldown(notify: _cooldownRemainingSeconds != 0);
return;
}
if (_cooldownRemainingSeconds != remaining) {
_startCooldown(remaining);
}
}
void _startCooldown(int seconds) {
_cooldownTimer?.cancel();
_cooldownRemainingSeconds = seconds;
if (_cooldownRemainingSeconds <= 0) {
_cooldownTimer = null;
notifyListeners();
return;
}
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_cooldownRemainingSeconds <= 1) {
_cooldownRemainingSeconds = 0;
_cooldownTimer?.cancel();
_cooldownTimer = null;
notifyListeners();
return;
}
_cooldownRemainingSeconds -= 1;
notifyListeners();
});
notifyListeners();
}
void _stopCooldown({bool notify = false}) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
final hadCooldown = _cooldownRemainingSeconds != 0;
_cooldownRemainingSeconds = 0;
if (notify && hadCooldown) {
notifyListeners();
}
}
@override
void dispose() {
_stopCooldown();
super.dispose();
}
}

View File

@@ -10,24 +10,49 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountAvatar extends StatelessWidget {
const AccountAvatar({super.key});
final double? size;
final bool showHeader;
final String? fallbackUrl;
final AccountProvider? provider;
const AccountAvatar({
super.key,
this.size,
this.showHeader = true,
this.fallbackUrl,
this.provider,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
if (provider != null) {
return _buildAvatar(context, provider!);
}
return Consumer<AccountProvider>(
builder: (context, provider, _) => UserAccountsDrawerHeader(
accountName: Text(provider.account?.name ?? loc.userNamePlaceholder),
accountEmail: Text(provider.account?.login ?? loc.usernameHint),
currentAccountPicture: CircleAvatar(
backgroundImage: (provider.account?.avatarUrl?.isNotEmpty ?? false)
? CachedNetworkImageProvider(provider.account!.avatarUrl!)
: null,
child: (provider.account?.avatarUrl?.isNotEmpty ?? false)
? null
: const Icon(Icons.account_circle, size: 50),
),
),
builder: (context, provider, _) => _buildAvatar(context, provider),
);
}
Widget _buildAvatar(BuildContext context, AccountProvider provider) {
final avatarUrl = (provider.account?.avatarUrl ?? fallbackUrl)?.trim();
final hasAvatar = avatarUrl?.isNotEmpty == true;
final radius = size != null ? size! / 2 : null;
final double placeholderIconSize = size != null ? size! * 0.55 : 50;
final avatar = CircleAvatar(
radius: radius,
backgroundImage: hasAvatar ? CachedNetworkImageProvider(avatarUrl!) : null,
child: hasAvatar ? null : Icon(Icons.account_circle, size: placeholderIconSize),
);
if (!showHeader) return avatar;
final loc = AppLocalizations.of(context)!;
return UserAccountsDrawerHeader(
accountName: Text(provider.account?.describable.name ?? loc.userNamePlaceholder),
accountEmail: Text(provider.account?.login ?? loc.usernameHint),
currentAccountPicture: avatar,
);
}
}

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/side_menu.dart';
import 'package:pweb/widgets/sidebar/user.dart';
@@ -18,7 +22,7 @@ class PayoutSidebar extends StatelessWidget {
final PayoutDestination selected;
final ValueChanged<PayoutDestination> onSelected;
final VoidCallback? onLogout;
final Future<void> Function()? onLogout;
final String? userName;
final String? avatarUrl;
@@ -27,6 +31,15 @@ class PayoutSidebar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
final accountAvatar = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl,
);
final resolvedUserName = userName ?? accountName;
final resolvedAvatarUrl = avatarUrl ?? accountAvatar;
final menuItems = items ??
<PayoutDestination>[
PayoutDestination.dashboard,
@@ -42,16 +55,16 @@ class PayoutSidebar extends StatelessWidget {
children: [
UserProfileCard(
theme: theme,
avatarUrl: avatarUrl,
userName: userName,
avatarUrl: resolvedAvatarUrl,
userName: resolvedUserName,
selected: selected,
onSelected: onSelected
),
const SizedBox(height: 8),
SideMenuColumn(
theme: theme,
avatarUrl: avatarUrl,
userName: userName,
avatarUrl: resolvedAvatarUrl,
userName: resolvedUserName,
items: menuItems,
selected: selected,
onSelected: onSelected,