From e1f58b0982233d2598c2b7c4d97cadb76e0f31fd Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 30 Jan 2026 16:39:12 +0100 Subject: [PATCH] bff dev upgrde --- .../billing/documents/v1/documents.proto | 101 ++++++++++++ .../common/account_role/v1/account_role.proto | 19 +++ api/proto/common/gateway/v1/gateway.proto | 1 + api/proto/connector/v1/connector.proto | 28 +++- api/proto/gateway/chain/v1/chain.proto | 9 +- api/proto/ledger/v1/ledger.proto | 55 ++++++- api/server/.air.toml | 89 +++++------ api/server/.gitignore | 3 +- api/server/assets/resources/logo.png | Bin 13588 -> 2217 bytes api/server/config.dev.yml | 121 ++++++++++++++ api/server/go.mod | 8 +- api/server/go.sum | 8 +- api/server/interface/api/srequest/ledger.go | 8 +- api/server/interface/api/sresponse/ledger.go | 4 +- api/server/interface/api/sresponse/wallet.go | 137 ++++++++++++++++ api/server/internal/mutil/proto/chain.go | 8 +- .../internal/server/ledgerapiimp/create.go | 18 ++- .../internal/server/ledgerapiimp/list.go | 22 ++- .../internal/server/walletapiimp/balance.go | 130 +++++++++++++-- .../internal/server/walletapiimp/create.go | 150 ++++++++++++++---- .../internal/server/walletapiimp/list.go | 140 +++++++++++++--- .../internal/server/walletapiimp/service.go | 95 ++++++----- 22 files changed, 969 insertions(+), 185 deletions(-) create mode 100644 api/proto/billing/documents/v1/documents.proto create mode 100644 api/proto/common/account_role/v1/account_role.proto create mode 100755 api/server/config.dev.yml diff --git a/api/proto/billing/documents/v1/documents.proto b/api/proto/billing/documents/v1/documents.proto new file mode 100644 index 00000000..1d989ac8 --- /dev/null +++ b/api/proto/billing/documents/v1/documents.proto @@ -0,0 +1,101 @@ +syntax = "proto3"; + +package billing.documents.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/billing/documents/v1;documentsv1"; + + +// --------------------------- +// ENUMS +// --------------------------- + +// DocumentType defines supported accounting document kinds. +enum DocumentType { + DOCUMENT_TYPE_UNSPECIFIED = 0; + + // Invoice issued for the payment + DOCUMENT_TYPE_INVOICE = 1; + + // Service acceptance act (common in EU/RU accounting) + DOCUMENT_TYPE_ACT = 2; + + // Simple receipt confirmation + DOCUMENT_TYPE_RECEIPT = 3; +} + + +// --------------------------- +// SERVICE +// --------------------------- + +// DocumentService provides document metadata for payment lists +// and lazy document generation on demand. +service DocumentService { + + // BatchResolveDocuments is used by BFF when rendering + // a page of payments. This prevents N+1 calls by resolving + // document metadata for many payments in a single request. + rpc BatchResolveDocuments(BatchResolveDocumentsRequest) + returns (BatchResolveDocumentsResponse); + + // GetDocument returns the actual PDF file. + // If the document was not generated before, the service + // generates it lazily, stores it, and returns it. + rpc GetDocument(GetDocumentRequest) + returns (GetDocumentResponse); +} + + +// --------------------------- +// BATCH RESOLVE (for payment tables) +// --------------------------- + +// BatchResolveDocumentsRequest contains a list of payment references +// for which document availability should be resolved. +message BatchResolveDocumentsRequest { + repeated string payment_refs = 1; +} + +// DocumentMeta describes document availability for a single payment. +message DocumentMeta { + // Payment reference + string payment_ref = 1; + + // Document types that are applicable for this payment + // based on business rules and payment snapshot. + repeated DocumentType available_types = 2; + + // Document types that were already generated and stored. + // Other available types will be generated lazily when requested. + repeated DocumentType ready_types = 3; +} + +// BatchResolveDocumentsResponse returns metadata for all requested payments. +message BatchResolveDocumentsResponse { + repeated DocumentMeta items = 1; +} + + +// --------------------------- +// GET DOCUMENT (lazy generation) +// --------------------------- + +// GetDocumentRequest requests a specific document for a payment. +message GetDocumentRequest { + string payment_ref = 1; + + // Type of document to retrieve (invoice, act, receipt, etc.) + DocumentType type = 2; +} + +// GetDocumentResponse returns the generated PDF content. +message GetDocumentResponse { + // Raw PDF bytes + bytes content = 1; + + // Suggested filename for download (e.g. invoice_123.pdf) + string filename = 2; + + // MIME type, typically "application/pdf" + string mime_type = 3; +} diff --git a/api/proto/common/account_role/v1/account_role.proto b/api/proto/common/account_role/v1/account_role.proto new file mode 100644 index 00000000..6e87bdd3 --- /dev/null +++ b/api/proto/common/account_role/v1/account_role.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package common.account_role.v1; +option go_package = "github.com/tech/sendico/pkg/proto/common/account_role/v1;accountrolev1"; + +enum AccountRole { + ACCOUNT_ROLE_UNSPECIFIED = 0; + OPERATING = 1; + HOLD = 2; + TRANSIT = 3; + SETTLEMENT = 4; + CLEARING = 5; + PENDING = 6; + RESERVE = 7; + LIQUIDITY = 8; + FEE = 9; + CHARGEBACK = 10; + ADJUSTMENT = 11; +} diff --git a/api/proto/common/gateway/v1/gateway.proto b/api/proto/common/gateway/v1/gateway.proto index 01568b1e..c01c0a6a 100644 --- a/api/proto/common/gateway/v1/gateway.proto +++ b/api/proto/common/gateway/v1/gateway.proto @@ -47,6 +47,7 @@ enum RailOperation { RAIL_OPERATION_FX_CONVERT = 6; RAIL_OPERATION_BLOCK = 7; RAIL_OPERATION_RELEASE = 8; + RAIL_OPERATION_MOVE = 9; } // Limits in minor units, e.g. cents diff --git a/api/proto/connector/v1/connector.proto b/api/proto/connector/v1/connector.proto index 438bf270..c0dc4e88 100644 --- a/api/proto/connector/v1/connector.proto +++ b/api/proto/connector/v1/connector.proto @@ -6,6 +6,8 @@ option go_package = "github.com/tech/sendico/pkg/proto/connector/v1;connectorv1" import "google/protobuf/struct.proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; +import "common/account_role/v1/account_role.proto"; import "common/describable/v1/describable.proto"; import "common/money/v1/money.proto"; import "common/pagination/v1/cursor.proto"; @@ -18,6 +20,7 @@ service ConnectorService { rpc GetAccount(GetAccountRequest) returns (GetAccountResponse); rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse); rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse); + rpc UpdateAccountState(UpdateAccountStateRequest) returns (UpdateAccountStateResponse); rpc SubmitOperation(SubmitOperationRequest) returns (SubmitOperationResponse); rpc GetOperation(GetOperationRequest) returns (GetOperationResponse); @@ -133,6 +136,7 @@ message Account { google.protobuf.Timestamp created_at = 8; google.protobuf.Timestamp updated_at = 9; common.describable.v1.Describable describable = 10; + common.account_role.v1.AccountRole role = 11; // functional role within the organization (ledger-only; unset for non-ledger connectors) } message Balance { @@ -167,6 +171,8 @@ message Operation { string provider_ref = 11; google.protobuf.Timestamp created_at = 12; google.protobuf.Timestamp updated_at = 13; + common.account_role.v1.AccountRole from_role = 14; + common.account_role.v1.AccountRole to_role = 15; } message OperationReceipt { @@ -192,6 +198,7 @@ message OpenAccountRequest { google.protobuf.Struct params = 6; string correlation_id = 7; string parent_intent_id = 8; + common.account_role.v1.AccountRole role = 9; // functional role (ledger-only; ignored by non-ledger connectors) } message OpenAccountResponse { @@ -208,11 +215,17 @@ message GetAccountResponse { } message ListAccountsRequest { - string owner_ref = 1; + reserved 1; + reserved "owner_ref"; AccountKind kind = 2; string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20) common.pagination.v1.CursorPageRequest page = 4; - string organization_ref = 5; // optional org scope (preferred over owner_ref) + string organization_ref = 5; + // Optional owner filter with 3-state semantics: + // - not set: return all accounts within organization + // - set to empty string: return accounts where owner_ref is null/empty + // - set to a value: return accounts where owner_ref matches + google.protobuf.StringValue owner_ref_filter = 6; } message ListAccountsResponse { @@ -220,6 +233,17 @@ message ListAccountsResponse { common.pagination.v1.CursorPageResponse page = 2; } +message UpdateAccountStateRequest { + AccountRef account_ref = 1; + AccountState target_state = 2; + common.account_role.v1.AccountRole source_role = 3; // optional: assert account has this role before mutation +} + +message UpdateAccountStateResponse { + Account account = 1; + ConnectorError error = 2; +} + message GetBalanceRequest { AccountRef account_ref = 1; } diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index 3f7efb1b..5b379d77 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -5,6 +5,7 @@ package chain.gateway.v1; option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; import "common/money/v1/money.proto"; import "common/pagination/v1/cursor.proto"; import "common/describable/v1/describable.proto"; @@ -85,9 +86,15 @@ message GetManagedWalletResponse { message ListManagedWalletsRequest { string organization_ref = 1; - string owner_ref = 2; + reserved 2; + reserved "owner_ref"; Asset asset = 3; common.pagination.v1.CursorPageRequest page = 4; + // Optional owner filter with 3-state semantics: + // - not set: return all wallets within organization + // - set to empty string: return wallets where owner_ref is null/empty + // - set to a value: return wallets where owner_ref matches + google.protobuf.StringValue owner_ref_filter = 5; } message ListManagedWalletsResponse { diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index e695a3c9..957b12bd 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -5,6 +5,7 @@ package ledger.v1; option go_package = "github.com/tech/sendico/pkg/proto/ledger/v1;ledgerv1"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; import "common/describable/v1/describable.proto"; import "common/money/v1/money.proto"; @@ -43,6 +44,21 @@ enum AccountStatus { ACCOUNT_STATUS_FROZEN = 2; } +enum AccountRole { + ACCOUNT_ROLE_UNSPECIFIED = 0; + ACCOUNT_ROLE_OPERATING = 1; + ACCOUNT_ROLE_HOLD = 2; + ACCOUNT_ROLE_TRANSIT = 3; + ACCOUNT_ROLE_SETTLEMENT = 4; + ACCOUNT_ROLE_CLEARING = 5; + ACCOUNT_ROLE_PENDING = 6; + ACCOUNT_ROLE_RESERVE = 7; + ACCOUNT_ROLE_LIQUIDITY = 8; + ACCOUNT_ROLE_FEE = 9; + ACCOUNT_ROLE_CHARGEBACK = 10; + ACCOUNT_ROLE_ADJUSTMENT = 11; +} + // LedgerAccount captures the canonical representation of an account resource. message LedgerAccount { string ledger_account_ref = 1; @@ -52,12 +68,14 @@ message LedgerAccount { string currency = 5; AccountStatus status = 6; bool allow_negative = 7; - bool is_settlement = 8; + reserved 8; + reserved "is_settlement"; map metadata = 9; google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp updated_at = 11; common.describable.v1.Describable describable = 12; string owner_ref = 13; + AccountRole role = 14; } // A single posting line (mirrors your PostingLine model) @@ -78,9 +96,11 @@ message CreateAccountRequest { string currency = 5; AccountStatus status = 6; bool allow_negative = 7; - bool is_settlement = 8; + reserved 8; + reserved "is_settlement"; map metadata = 9; common.describable.v1.Describable describable = 10; + AccountRole role = 11; } message CreateAccountResponse { @@ -98,6 +118,7 @@ message PostCreditRequest { map metadata = 7; google.protobuf.Timestamp event_time = 8; string contra_ledger_account_ref = 9; // optional override for settlement/contra account + AccountRole role = 10; // optional: assert target account has this role } message PostDebitRequest { @@ -110,6 +131,7 @@ message PostDebitRequest { map metadata = 7; google.protobuf.Timestamp event_time = 8; string contra_ledger_account_ref = 9; // optional override for settlement/contra account + AccountRole role = 10; // optional: assert target account has this role } message TransferRequest { @@ -122,6 +144,8 @@ message TransferRequest { repeated PostingLine charges = 7; // optional FEE/SPREAD lines map metadata = 8; google.protobuf.Timestamp event_time = 9; + AccountRole from_role = 10; + AccountRole to_role = 11; } message FXRequest { @@ -188,8 +212,35 @@ message StatementResponse { message ListAccountsRequest { string organization_ref = 1; + // Optional owner filter with 3-state semantics: + // - not set: return all accounts within organization + // - set to empty string: return accounts where owner_ref is null/empty + // - set to a value: return accounts where owner_ref matches + google.protobuf.StringValue owner_ref_filter = 2; } message ListAccountsResponse { repeated LedgerAccount accounts = 1; } + +// ---- Account status mutations ---- + +message BlockAccountRequest { + string ledger_account_ref = 1; + string organization_ref = 2; + AccountRole role = 3; // optional: assert account has this role before blocking +} + +message BlockAccountResponse { + LedgerAccount account = 1; +} + +message UnblockAccountRequest { + string ledger_account_ref = 1; + string organization_ref = 2; + AccountRole role = 3; // optional: assert account has this role before unblocking +} + +message UnblockAccountResponse { + LedgerAccount account = 1; +} diff --git a/api/server/.air.toml b/api/server/.air.toml index c0d65bf4..16f8c34b 100644 --- a/api/server/.air.toml +++ b/api/server/.air.toml @@ -1,57 +1,46 @@ -# Config file for [Air](https://github.com/air-verse/air) in TOML format - -# Working directory -# . or absolute path, please note that the directories following must be under root. -root = "./.." +root = "." +testdata_dir = "testdata" tmp_dir = "tmp" [build] -# Just plain old shell command. You could use `make` as well. -cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/server/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/server/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/server/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/server/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/server/internal/appversion.BuildDate=$(date)' -X 'github.com/tech/sendico/server/internal/mutil/ampli.Version=$APP_V'\"" -# Binary file yields from `cmd`. -bin = "./app" -# Customize binary, can setup environment variables when run your app. -full_bin = "./app --debug" -# Watch these filename extensions. -include_ext = ["go"] -# Ignore these filename extensions or directories. -exclude_dir = ["server/.git", "pkg/.git", "server/tmp", "server/storage", "server/resources", "server/env"] -# Watch these directories if you specified. -include_dir = [] -# Watch these files. -include_file = [] -# Exclude files. -exclude_file = [] -# Exclude specific regular expressions. -exclude_regex = ["_test\\.go"] -# Exclude unchanged files. -exclude_unchanged = true -# Follow symlink for directories -follow_symlink = true -# This log file places in your tmp_dir. -log = "air.log" -# It's not necessary to trigger build each time file changes if it's too frequent. -delay = 0 # ms -# Stop running old binary when build errors occur. -stop_on_error = true -# Send Interrupt signal before killing process (windows does not support this feature) -send_interrupt = true -# Delay after sending Interrupt signal -kill_delay = 500 # ms -# Add additional arguments when running binary (bin/full_bin). Will run './tmp/main hello world'. -args_bin = [] - -[log] -# Show log time -time = false + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false [color] -# Customize each part's color. If no color found, use the raw app log. -main = "magenta" -watcher = "cyan" -build = "yellow" -runner = "green" + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false [misc] -# Delete tmp directory on exit -clean_on_exit = true \ No newline at end of file + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/server/.gitignore b/api/server/.gitignore index a5b14dd9..14319d5b 100644 --- a/api/server/.gitignore +++ b/api/server/.gitignore @@ -1,4 +1,5 @@ /app /server /storage -.gocache \ No newline at end of file +.gocache +tmp diff --git a/api/server/assets/resources/logo.png b/api/server/assets/resources/logo.png index 90b3760e6bbc510cf75e3922b4154b1584cd468f..b6ff8b33714ccf5a608b8b0df41382a13de50200 100644 GIT binary patch literal 2217 zcmbVOdpy(o8~@7f2#ci}_6u#Sa!a|aqJ^=2ITGnIgjME9Wed5qAId6gOq+~YQAm!W z8D}n`+(r|xlxu2it|8S?;<%jc{4THGU%x-j^Land^LpN&=kq+z=bz8(LqB{7r@3Bl zJpcfjARg;2^KEkdNkwL#oG85~a|#jOI46aHcC!RopcLZh;Rpb{H1%bovMe?{jraBd zfH)HXNTdP4l8j261c0br0Pryg0QO}8z!oyM#>-x|p>oO{hXvN;E3d97UB;-9@q`Ef zfHcchA;T1+FC$eWK@S(zDGj9!5M}Qwr9>Gs3t}C8Vg_g5yQk?Kh28j);Ms+RVi9gS z3dV_$^-7ehK^ix&qzt=auF$FKeUJTLs2Wm4PRC&z30hD@z+vqx&&hN(r>dkxL`Pxm z$+vr_oc-RPY>D(}C>mchChdE4UtIdT&(Xe)NKn@Q?@JKAGA$w6rTZWlfFn72+8s1* z%54cU9E;t6wP(2PcCWzQQUEm$68w(kL(O&Np zZD5aYY@gx2oV{eZq$to{#>8)+nqS2)KN#!_=2(H2u)q`X!l>sC6E|MKD}y+>#ttB| zW&dQr=P8a$&n@e*`he((=NyFY?Z_bqvF}T>_;9MpRs48uezbrPKZ5su=6rq~-JER{ zt(WoJBi}8(C&)Fy6pk|cf^yNZZ?}c%aF8^i6P9-QKFZhD$rovta__?8k;f~#BsE0r zYcleN-@WiU5sQXjzpwA)r&1=(D3Z}U$ZA&IEn(79hr@aiU{60Y%sS@Chft2%A;C>C zY;JpXMUD-7`Z{89yR`8ui)#~8BfD)z1ScO3&R|b{!F&I+1K+BF`?`bGU$xGMgI96a z0c}E%8=s+!pxfGQ)mbpcnSZdd7^BCHGgIiz2a$qg8eR@1tP|1@x=n$lrS@;Yl1I60 zaXk_$V-o+$`B&!uTC650$e=D6vHEY0W)&3Cn_ZF7|F57YXe#T}9Zy^-94$SLUAHZi z|I`AiCOdKvDI4Iw2u?YpD4#OAC8~DT8+TDq!16()jmBuT1EP0_kUdard1HU08>8SM zZtmR&3-+~7Yfqj&V=&ItG_`(a`KMQO^tVoK25YZHnV{3kuzK_cde;J%v`N1`iF2)I z@~PE#J^MKiMgfP&>C)(wzJFijAnd@seAlk%mBt^3yJJKFf~d_2 z*yX{=OJDfZ?u(=NoE8IZjHSdu;-7H(4LQ(rXLZC;R7Q~E3H!cpT~jo+Y4`~BE{WGx zCK4l8-UM&n6q}9CR4lH^Z*c-KJF6FnPsVIQl`K~)E>rEt8u(-+ms8=^?aAgu-YFd4 zYOp7+20dnx4EOq4jQR%Q{}5)PjXtgzEfWO8O?G9W;VLUrJa72=*YGAV^V?d< z#k&@0Gpo24#3Xjfb%G_Dd+c|U2mb9JiEz{r$s++h-y-w|&*1`0Sg67Ofo?Uf3$~uO zOG20X$2UmEC(NDGq-fS2=H2VkDlUTg2isOZCwP0ZG@uiGy*e%!1qlY-(>IzfQi2L+I(Pp%e%|Omvpphw zji;GV^5!WT+Nu;Co_6Dgz|QhKlMLzt2Z7`9XWDdyv7qHf+?=$Ey@&b4b<0~kUD97X z@0oLMi*T<^c(XHnkB>PK9*dz4oPzi0HuG+y<4)&cDLzT)R(GfnBAGNu5LZngPagiR zU;i_V+BM=FSV+YjxF`yO$8%C&E_ggwZ}#d4I4js%w|KdzDYF4vRy{ZVYg7qauut~xg`Era4y~z*c=W(S^fEK-t;1Xep~}}rq&}?~y&4c*v@HruX!|fr=XaF1 znCFo>h?~$kRAqZVfP)m9%bjA*#WxwYF3bXKK$N9mhz zhNs!%U|dh#z=~fxs>8ILu=CR|yo2py)2JcV>N(NltQLo3y_gWrYNz)d5u>tuqM;Vb r{exq))E`o;6!t*lo&00)cco-ikxur2H0=sx$V-4Ohp?qiL0A6-)=AW% literal 13588 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 S98U)#Ak;atvt?({|NJkZ4A30_ diff --git a/api/server/config.dev.yml b/api/server/config.dev.yml new file mode 100755 index 00000000..db1ea76b --- /dev/null +++ b/api/server/config.dev.yml @@ -0,0 +1,121 @@ +http_server: + listen_address: :8080 + read_header_timeout: 60 + shutdown_timeout: 5 + +api: + amplitude: + ampli_environment_env: AMPLI_ENVIRONMENT + middleware: + api_protocol_env: API_PROTOCOL + domain_env: SERVICE_HOST + api_endpoint_env: API_ENDPOINT + signature: + secret_key_env: API_ENDPOINT_SECRET + algorithm: HS256 + CORS: + max_age: 300 + allowed_origins: + - "*" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-Requested-With" + exposed_headers: + allow_credentials: false + websocket: + endpoint_env: WS_ENDPOINT + timeout: 60 + message_broker: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Sendico Backend server + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + # type: in-process + # settings: + # buffer_size: 10 + token: + expiration_hours: + account: 24 + refresh: 720 + length: 32 + password: + token_length: 32 + check: + min_length: 8 + digit: true + upper: true + lower: true + special: true + + + storage: + # driver: aws_s3 + # settings: + # access_key_id_env: S3_ACCESS_KEY_ID + # secret_access_key_env: S3_ACCESS_KEY_SECRET + # region_env: S3_REGION + # bucket_name_env: S3_BUCKET_NAME + driver: local_fs + settings: + root_path: ./storage + + chain_gateway: + address: dev-chain-gateway:50070 + address_env: CHAIN_GATEWAY_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + default_asset: + chain: TRON_NILE + token_symbol: USDT + contract_address: "" + ledger: + address: dev-ledger:50052 + address_env: LEDGER_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + payment_orchestrator: + address: dev-payments-orchestrator:50062 + address_env: PAYMENTS_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +app: + +database: + driver: mongodb + settings: + host_env: MONGO_HOST + port_env: MONGO_PORT + database_env: MONGO_DATABASE + user_env: MONGO_USER + password_env: MONGO_PASSWORD + auth_source_env: MONGO_AUTH_SOURCE + replica_set_env: MONGO_REPLICA_SET + enforcer: + driver: native + settings: + model_path_env: PERMISSION_MODEL + adapter: + collection_name_env: PERMISSION_COLLECTION + database_name_env: MONGO_DATABASE + timeout_seconds_env: PERMISSION_TIMEOUT + is_filtered_env: PERMISSION_IS_FILTERED diff --git a/api/server/go.mod b/api/server/go.mod index 96a1c80d..5aa24a6e 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -1,6 +1,6 @@ module github.com/tech/sendico/server -go 1.25.3 +go 1.25.6 replace github.com/tech/sendico/pkg => ../pkg @@ -14,7 +14,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.7 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/go-chi/chi/v5 v5.2.4 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -32,6 +32,7 @@ require ( go.mongodb.org/mongo-driver v1.17.7 go.uber.org/zap v1.27.1 golang.org/x/net v0.49.0 + google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 moul.io/chizap v1.0.3 @@ -139,6 +140,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect ) diff --git a/api/server/go.sum b/api/server/go.sum index 9e481d28..372183ef 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -32,8 +32,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMooz github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= @@ -361,8 +361,8 @@ 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-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-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/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= diff --git a/api/server/interface/api/srequest/ledger.go b/api/server/interface/api/srequest/ledger.go index dabaa99c..71d3c55e 100644 --- a/api/server/interface/api/srequest/ledger.go +++ b/api/server/interface/api/srequest/ledger.go @@ -3,6 +3,7 @@ package srequest import ( "strings" + "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/bson/primitive" @@ -30,7 +31,7 @@ type CreateLedgerAccount struct { AccountType LedgerAccountType `json:"accountType"` Currency string `json:"currency"` AllowNegative bool `json:"allowNegative,omitempty"` - IsSettlement bool `json:"isSettlement,omitempty"` + Role model.AccountRole `json:"role"` Describable model.Describable `json:"describable"` OwnerRef *primitive.ObjectID `json:"ownerRef,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` @@ -43,5 +44,10 @@ func (r *CreateLedgerAccount) Validate() error { if strings.TrimSpace(string(r.AccountType)) == "" || strings.EqualFold(string(r.AccountType), string(LedgerAccountTypeUnspecified)) { return merrors.InvalidArgument("accountType is required", "accountType") } + if role := strings.TrimSpace(string(r.Role)); role != "" { + if _, ok := ledgerconv.ParseAccountRole(role); !ok || ledgerconv.IsAccountRoleUnspecified(role) { + return merrors.InvalidArgument("role is invalid", "role") + } + } return nil } diff --git a/api/server/interface/api/sresponse/ledger.go b/api/server/interface/api/sresponse/ledger.go index 0a74fc7a..c859e817 100644 --- a/api/server/interface/api/sresponse/ledger.go +++ b/api/server/interface/api/sresponse/ledger.go @@ -21,7 +21,7 @@ type ledgerAccount struct { Currency string `json:"currency"` Status string `json:"status"` AllowNegative bool `json:"allowNegative"` - IsSettlement bool `json:"isSettlement"` + Role string `json:"role"` Metadata map[string]string `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` UpdatedAt time.Time `json:"updatedAt,omitempty"` @@ -96,7 +96,7 @@ func toLedgerAccount(acc *ledgerv1.LedgerAccount) ledgerAccount { Currency: acc.GetCurrency(), Status: acc.GetStatus().String(), AllowNegative: acc.GetAllowNegative(), - IsSettlement: acc.GetIsSettlement(), + Role: acc.GetRole().String(), Metadata: acc.GetMetadata(), CreatedAt: acc.GetCreatedAt().AsTime(), UpdatedAt: acc.GetUpdatedAt().AsTime(), diff --git a/api/server/interface/api/sresponse/wallet.go b/api/server/interface/api/sresponse/wallet.go index 34b044e2..2470ea64 100644 --- a/api/server/interface/api/sresponse/wallet.go +++ b/api/server/interface/api/sresponse/wallet.go @@ -1,6 +1,7 @@ package sresponse import ( + "fmt" "net/http" "strings" "time" @@ -8,7 +9,9 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -153,3 +156,137 @@ func chainNetworkValue(chain chainv1.ChainNetwork) string { } return strings.ToLower(trimmed) } + +// WalletsFromAccounts converts connector accounts to wallet response format. +// Used when querying multiple gateways via discovery. +func WalletsFromAccounts(logger mlogger.Logger, accounts []*connectorv1.Account, accessToken *TokenData) http.HandlerFunc { + dto := walletsResponse{ + authResponse: authResponse{AccessToken: *accessToken}, + } + dto.Wallets = make([]wallet, 0, len(accounts)) + for _, acc := range accounts { + if acc == nil { + continue + } + dto.Wallets = append(dto.Wallets, accountToWallet(acc)) + } + return response.Ok(logger, dto) +} + +func accountToWallet(acc *connectorv1.Account) wallet { + if acc == nil { + return wallet{} + } + + // Extract wallet details from provider details + details := map[string]interface{}{} + if acc.GetProviderDetails() != nil { + details = acc.GetProviderDetails().AsMap() + } + + walletRef := "" + if ref := acc.GetRef(); ref != nil { + walletRef = strings.TrimSpace(ref.GetAccountId()) + } + if v := stringFromDetails(details, "wallet_ref"); v != "" { + walletRef = v + } + + organizationRef := stringFromDetails(details, "organization_ref") + ownerRef := strings.TrimSpace(acc.GetOwnerRef()) + if v := stringFromDetails(details, "owner_ref"); v != "" { + ownerRef = v + } + + chain := stringFromDetails(details, "network") + tokenSymbol := stringFromDetails(details, "token_symbol") + contractAddress := stringFromDetails(details, "contract_address") + depositAddress := stringFromDetails(details, "deposit_address") + + name := "" + if d := acc.GetDescribable(); d != nil { + name = strings.TrimSpace(d.GetName()) + } + if name == "" { + name = strings.TrimSpace(acc.GetLabel()) + } + if name == "" { + name = walletRef + } + + var description *string + if d := acc.GetDescribable(); d != nil && d.Description != nil { + if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" { + description = &trimmed + } + } + + status := acc.GetState().String() + // Convert connector state to wallet status format + switch acc.GetState() { + case connectorv1.AccountState_ACCOUNT_ACTIVE: + status = "MANAGED_WALLET_ACTIVE" + case connectorv1.AccountState_ACCOUNT_SUSPENDED: + status = "MANAGED_WALLET_SUSPENDED" + case connectorv1.AccountState_ACCOUNT_CLOSED: + status = "MANAGED_WALLET_CLOSED" + } + + return wallet{ + WalletRef: walletRef, + OrganizationRef: organizationRef, + OwnerRef: ownerRef, + Asset: walletAsset{ + Chain: chain, + TokenSymbol: tokenSymbol, + ContractAddress: contractAddress, + }, + DepositAddress: depositAddress, + Status: status, + Name: name, + Description: description, + CreatedAt: tsToString(acc.GetCreatedAt()), + UpdatedAt: tsToString(acc.GetUpdatedAt()), + } +} + +func stringFromDetails(details map[string]interface{}, key string) string { + if details == nil { + return "" + } + if value, ok := details[key]; ok { + return strings.TrimSpace(fmt.Sprint(value)) + } + return "" +} + +// WalletBalanceFromConnector converts connector balance to wallet balance response format. +// Used when querying gateways via discovery. +func WalletBalanceFromConnector(logger mlogger.Logger, bal *connectorv1.Balance, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, walletBalanceResponse{ + Balance: connectorBalanceToWalletBalance(bal), + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func connectorBalanceToWalletBalance(b *connectorv1.Balance) walletBalance { + if b == nil { + return walletBalance{} + } + return walletBalance{ + Available: connectorMoneyToModel(b.GetAvailable()), + PendingInbound: connectorMoneyToModel(b.GetPendingInbound()), + PendingOutbound: connectorMoneyToModel(b.GetPendingOutbound()), + CalculatedAt: tsToString(b.GetCalculatedAt()), + } +} + +func connectorMoneyToModel(m *moneyv1.Money) *model.Money { + if m == nil { + return nil + } + return &model.Money{ + Amount: m.GetAmount(), + Currency: m.GetCurrency(), + } +} diff --git a/api/server/internal/mutil/proto/chain.go b/api/server/internal/mutil/proto/chain.go index 2a01a73e..d28b439e 100644 --- a/api/server/internal/mutil/proto/chain.go +++ b/api/server/internal/mutil/proto/chain.go @@ -10,18 +10,18 @@ import ( func Network2Proto(network model.ChainNetwork) (chainv1.ChainNetwork, error) { switch network { - case model.ChainNetworkARB: + case model.ChainNetworkArbitrumOne: return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil - case model.ChainNetworkEthMain: + case model.ChainNetworkEthereumMainnet: return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil - case model.ChainNetworkTronMain: + case model.ChainNetworkTronMainnet: return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil case model.ChainNetworkTronNile: return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil case model.ChainNetworkUnspecified: return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil default: - return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("Unkwnown chain network value '%s'", network), "network") + return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("unknown chain network value '%s'", network), "network") } } diff --git a/api/server/internal/server/ledgerapiimp/create.go b/api/server/internal/server/ledgerapiimp/create.go index 30c77d5e..986140e2 100644 --- a/api/server/internal/server/ledgerapiimp/create.go +++ b/api/server/internal/server/ledgerapiimp/create.go @@ -47,6 +47,10 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token if err != nil { return response.BadPayload(a.logger, a.Name(), err) } + accountRole, err := mapLedgerAccountRole(payload.Role) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } if a.client == nil { return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured")) } @@ -78,7 +82,7 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token Currency: payload.Currency, Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, AllowNegative: payload.AllowNegative, - IsSettlement: payload.IsSettlement, + Role: accountRole, Metadata: payload.Metadata, Describable: describable, }) @@ -128,14 +132,14 @@ func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.Acco return parsed, nil } -func mapLedgerAccountStatus(status srequest.LedgerAccountStatus) (ledgerv1.AccountStatus, error) { - raw := string(status) - if ledgerconv.IsAccountStatusUnspecified(raw) { - return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, nil +func mapLedgerAccountRole(role model.AccountRole) (ledgerv1.AccountRole, error) { + raw := strings.TrimSpace(string(role)) + if ledgerconv.IsAccountRoleUnspecified(raw) { + return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, nil } - parsed, ok := ledgerconv.ParseAccountStatus(raw) + parsed, ok := ledgerconv.ParseAccountRole(raw) if !ok { - return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, merrors.InvalidArgument("unsupported status: "+string(status), "status") + return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED, merrors.InvalidArgument("unsupported role: "+raw, "role") } return parsed, nil } diff --git a/api/server/internal/server/ledgerapiimp/list.go b/api/server/internal/server/ledgerapiimp/list.go index 1f735b9c..27488139 100644 --- a/api/server/internal/server/ledgerapiimp/list.go +++ b/api/server/internal/server/ledgerapiimp/list.go @@ -7,11 +7,13 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/mutil/mzap" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" + "google.golang.org/protobuf/types/known/wrapperspb" ) func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { @@ -22,22 +24,28 @@ func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token } ctx := r.Context() - res, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + hasReadPermission, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) if err != nil { a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), mutil.PLog(a.oph, r)) return response.Auto(a.logger, a.Name(), err) } - if !res { - a.logger.Debug("Access denied when listing ledger accounts", mutil.PLog(a.oph, r)) - return response.AccessDenied(a.logger, a.Name(), "ledger accounts read permission denied") - } if a.client == nil { return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured")) } - resp, err := a.client.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{ + req := &ledgerv1.ListAccountsRequest{ OrganizationRef: orgRef.Hex(), - }) + } + + // If user has read permission, return all accounts in organization. + // Otherwise, filter to only accounts owned by the requesting account. + if !hasReadPermission { + req.OwnerRefFilter = wrapperspb.String(account.ID.Hex()) + a.logger.Debug("Filtering ledger accounts by owner due to limited permissions", + mzap.ObjRef("owner_ref", account.ID), mutil.PLog(a.oph, r)) + } + + resp, err := a.client.ListAccounts(ctx, req) if err != nil { a.logger.Warn("Failed to list ledger accounts", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) return response.Auto(a.logger, mservice.Ledger, err) diff --git a/api/server/internal/server/walletapiimp/balance.go b/api/server/internal/server/walletapiimp/balance.go index 1e8c7809..96f62e27 100644 --- a/api/server/internal/server/walletapiimp/balance.go +++ b/api/server/internal/server/walletapiimp/balance.go @@ -1,18 +1,25 @@ package walletapiimp import ( + "context" + "crypto/tls" "net/http" "strings" + "sync" "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" ) func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { @@ -36,21 +43,126 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to a.logger.Debug("Access denied when reading wallet balance", mutil.PLog(a.oph, r), zap.String("wallet_ref", walletRef)) return response.AccessDenied(a.logger, a.Name(), "wallet balance read permission denied") } - if a.chainGateway == nil { - return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured")) + + if a.discovery == nil { + return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("discovery client is not configured")) } - resp, err := a.chainGateway.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: walletRef}) + // Discover CRYPTO rail gateways + lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout) + defer cancel() + + lookupResp, err := a.discovery.Lookup(lookupCtx) if err != nil { - a.logger.Warn("Failed to fetch wallet balance", zap.Error(err), zap.String("wallet_ref", walletRef)) + a.logger.Warn("Failed to lookup discovery registry", zap.Error(err)) + return response.Auto(a.logger, a.Name(), err) + } + + // Filter gateways by CRYPTO rail + cryptoGateways := filterCryptoGateways(lookupResp.Gateways) + if len(cryptoGateways) == 0 { + a.logger.Debug("No CRYPTO rail gateways found in discovery") + return response.Auto(a.logger, a.Name(), merrors.NoData("no crypto gateways available")) + } + + // Query all gateways in parallel to find the wallet balance + bal, err := a.queryBalanceFromGateways(ctx, cryptoGateways, walletRef) + if err != nil { + a.logger.Warn("Failed to fetch wallet balance from gateways", zap.Error(err), zap.String("wallet_ref", walletRef)) return response.Auto(a.logger, mservice.ChainGateway, err) } - bal := resp.GetBalance() if bal == nil { - a.logger.Warn("Wallet balance missing in response", zap.String("wallet_ref", walletRef)) - return response.Auto(a.logger, mservice.ChainGateway, merrors.Internal("wallet balance not available")) + a.logger.Warn("Wallet balance not found on any gateway", zap.String("wallet_ref", walletRef)) + return response.Auto(a.logger, mservice.ChainGateway, merrors.NoData("wallet not found")) } - return sresponse.WalletBalance(a.logger, bal, token) + return sresponse.WalletBalanceFromConnector(a.logger, bal, token) +} + +func (a *WalletAPI) queryBalanceFromGateways(ctx context.Context, gateways []discovery.GatewaySummary, walletRef string) (*connectorv1.Balance, error) { + var mu sync.Mutex + var wg sync.WaitGroup + var result *connectorv1.Balance + var lastErr error + + for _, gw := range gateways { + wg.Add(1) + go func(gateway discovery.GatewaySummary) { + defer wg.Done() + + bal, err := a.queryGatewayBalance(ctx, gateway, walletRef) + if err != nil { + a.logger.Debug("Failed to query gateway for balance", + zap.String("gateway_id", gateway.ID), + zap.String("invoke_uri", gateway.InvokeURI), + zap.String("wallet_ref", walletRef), + zap.Error(err)) + mu.Lock() + lastErr = err + mu.Unlock() + return + } + + if bal != nil { + mu.Lock() + if result == nil { + result = bal + a.logger.Debug("Found wallet balance on gateway", + zap.String("gateway_id", gateway.ID), + zap.String("network", gateway.Network), + zap.String("wallet_ref", walletRef)) + } + mu.Unlock() + } + }(gw) + } + + wg.Wait() + + if result != nil { + return result, nil + } + if lastErr != nil { + return nil, lastErr + } + return nil, nil +} + +func (a *WalletAPI) queryGatewayBalance(ctx context.Context, gateway discovery.GatewaySummary, walletRef string) (*connectorv1.Balance, error) { + // Create connection with timeout + dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout) + defer cancel() + + var dialOpts []grpc.DialOption + if a.insecure { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) + } + + conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...) + if err != nil { + return nil, merrors.InternalWrap(err, "dial gateway") + } + defer conn.Close() + + client := connectorv1.NewConnectorServiceClient(conn) + + // Call with timeout + callCtx, callCancel := context.WithTimeout(ctx, a.callTimeout) + defer callCancel() + + req := &connectorv1.GetBalanceRequest{ + AccountRef: &connectorv1.AccountRef{ + AccountId: walletRef, + }, + } + + resp, err := client.GetBalance(callCtx, req) + if err != nil { + return nil, err + } + + return resp.GetBalance(), nil } diff --git a/api/server/internal/server/walletapiimp/create.go b/api/server/internal/server/walletapiimp/create.go index a688b2ac..0c5615cb 100644 --- a/api/server/internal/server/walletapiimp/create.go +++ b/api/server/internal/server/walletapiimp/create.go @@ -1,24 +1,29 @@ package walletapiimp import ( + "context" + "crypto/tls" "encoding/json" "net/http" "strings" "github.com/google/uuid" "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mutil/mzap" - describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" - ast "github.com/tech/sendico/server/internal/mutil/proto" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" ) func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { @@ -52,47 +57,134 @@ func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresp return response.Auto(a.logger, a.Name(), err) } - if a.chainGateway == nil { - return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured")) + if a.discovery == nil { + return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("discovery client is not configured")) + } + + // Find gateway for this network + lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout) + defer cancel() + + lookupResp, err := a.discovery.Lookup(lookupCtx) + if err != nil { + a.logger.Warn("Failed to lookup discovery registry", zap.Error(err)) + return response.Auto(a.logger, a.Name(), err) + } + + // Find gateway that handles this network + networkName := strings.ToLower(string(asset.Asset.Chain)) + gateway := findGatewayForNetwork(lookupResp.Gateways, networkName) + if gateway == nil { + a.logger.Warn("No gateway found for network", + zap.String("network", networkName), + zap.String("chain", string(sr.Asset.Chain))) + return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("no gateway available for network: "+networkName)) } var ownerRef string if sr.OwnerRef != nil && !sr.OwnerRef.IsZero() { ownerRef = sr.OwnerRef.Hex() } - passet, err := ast.Asset2Proto(&asset.Asset) - if err != nil { - a.logger.Warn("Failed to convert asset to proto asset", zap.Error(err), - mzap.StorableRef(asset), mzap.StorableRef(account)) - return response.Auto(a.logger, a.Name(), err) + + // Build params for connector OpenAccount + params := map[string]interface{}{ + "organization_ref": orgRef.Hex(), + "network": networkName, + "token_symbol": asset.Asset.TokenSymbol, + "contract_address": asset.Asset.ContractAddress, + } + if sr.Description.Description != nil { + params["description"] = *sr.Description.Description + } + params["metadata"] = map[string]interface{}{ + "source": "create", + "login": account.Login, } - req := &chainv1.CreateManagedWalletRequest{ - IdempotencyKey: uuid.NewString(), - OrganizationRef: orgRef.Hex(), - OwnerRef: ownerRef, - Describable: &describablev1.Describable{ - Name: sr.Description.Name, - Description: sr.Description.Description, - }, - Asset: passet, - Metadata: map[string]string{ - "source": "create", - "login": account.Login, - }, + paramsStruct, _ := structpb.NewStruct(params) + assetString := networkName + "-" + asset.Asset.TokenSymbol + + req := &connectorv1.OpenAccountRequest{ + IdempotencyKey: uuid.NewString(), + Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET, + Asset: assetString, + OwnerRef: ownerRef, + Label: sr.Description.Name, + Params: paramsStruct, } - resp, err := a.chainGateway.CreateManagedWallet(ctx, req) + // Connect to gateway and create wallet + walletRef, err := a.createWalletOnGateway(ctx, *gateway, req) if err != nil { - a.logger.Warn("Failed to create managed wallet", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account)) + a.logger.Warn("Failed to create managed wallet", zap.Error(err), + mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account), + zap.String("gateway_id", gateway.ID), zap.String("network", gateway.Network)) return response.Auto(a.logger, a.Name(), err) } - if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" { - return response.Auto(a.logger, a.Name(), merrors.Internal("chain gateway returned empty wallet reference")) - } a.logger.Info("Managed wallet created for organization", mzap.ObjRef("organization_ref", orgRef), - zap.String("wallet_ref", resp.Wallet.WalletRef), mzap.StorableRef(account)) + zap.String("wallet_ref", walletRef), mzap.StorableRef(account), + zap.String("gateway_id", gateway.ID), zap.String("network", gateway.Network)) return sresponse.Success(a.logger, token) } + +func findGatewayForNetwork(gateways []discovery.GatewaySummary, network string) *discovery.GatewaySummary { + network = strings.ToLower(strings.TrimSpace(network)) + for _, gw := range gateways { + if !strings.EqualFold(gw.Rail, cryptoRail) || !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" { + continue + } + // Check if gateway network matches + gwNetwork := strings.ToLower(strings.TrimSpace(gw.Network)) + if gwNetwork == network { + return &gw + } + // Also check if network starts with gateway network prefix (e.g., "tron" matches "tron_mainnet") + if strings.HasPrefix(network, gwNetwork) || strings.HasPrefix(gwNetwork, network) { + return &gw + } + } + return nil +} + +func (a *WalletAPI) createWalletOnGateway(ctx context.Context, gateway discovery.GatewaySummary, req *connectorv1.OpenAccountRequest) (string, error) { + // Create connection with timeout + dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout) + defer cancel() + + var dialOpts []grpc.DialOption + if a.insecure { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) + } + + conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...) + if err != nil { + return "", merrors.InternalWrap(err, "dial gateway") + } + defer conn.Close() + + client := connectorv1.NewConnectorServiceClient(conn) + + // Call with timeout + callCtx, callCancel := context.WithTimeout(ctx, a.callTimeout) + defer callCancel() + + resp, err := client.OpenAccount(callCtx, req) + if err != nil { + return "", err + } + + if resp.GetError() != nil { + return "", merrors.Internal(resp.GetError().GetMessage()) + } + + account := resp.GetAccount() + if account == nil || account.GetRef() == nil { + return "", merrors.Internal("gateway returned empty account") + } + + return strings.TrimSpace(account.GetRef().GetAccountId()), nil +} diff --git a/api/server/internal/server/walletapiimp/list.go b/api/server/internal/server/walletapiimp/list.go index 52a95382..4c0373fe 100644 --- a/api/server/internal/server/walletapiimp/list.go +++ b/api/server/internal/server/walletapiimp/list.go @@ -1,18 +1,27 @@ package walletapiimp import ( + "context" + "crypto/tls" "net/http" "strings" + "sync" "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "github.com/tech/sendico/pkg/mutil/mzap" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/wrapperspb" ) func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { @@ -23,31 +32,126 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token * } ctx := r.Context() - res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + hasReadPermission, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) if err != nil { a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r)) return response.Auto(a.logger, a.Name(), err) } - if !res { - a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r)) - return response.AccessDenied(a.logger, a.Name(), "wallets read permission denied") - } - if a.chainGateway == nil { - return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured")) + + if a.discovery == nil { + return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("discovery client is not configured")) } - req := &chainv1.ListManagedWalletsRequest{ - OrganizationRef: orgRef.Hex(), - } - if owner := strings.TrimSpace(r.URL.Query().Get("owner_ref")); owner != "" { - req.OwnerRef = owner - } + // Discover CRYPTO rail gateways + lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout) + defer cancel() - resp, err := a.chainGateway.ListManagedWallets(ctx, req) + lookupResp, err := a.discovery.Lookup(lookupCtx) if err != nil { - a.logger.Warn("Failed to list managed wallets", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) - return response.Auto(a.logger, mservice.ChainGateway, err) + a.logger.Warn("Failed to lookup discovery registry", zap.Error(err)) + return response.Auto(a.logger, a.Name(), err) } - return sresponse.Wallets(a.logger, resp, token) + // Filter gateways by CRYPTO rail + cryptoGateways := filterCryptoGateways(lookupResp.Gateways) + if len(cryptoGateways) == 0 { + a.logger.Debug("No CRYPTO rail gateways found in discovery") + return sresponse.Wallets(a.logger, nil, token) + } + + // Build request + req := &connectorv1.ListAccountsRequest{ + OrganizationRef: orgRef.Hex(), + Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET, + } + + // If user has read permission, return all wallets in organization. + // Otherwise, filter to only wallets owned by the requesting account. + if !hasReadPermission { + req.OwnerRefFilter = wrapperspb.String(account.ID.Hex()) + a.logger.Debug("Filtering wallets by owner due to limited permissions", + mzap.ObjRef("owner_ref", account.ID), mutil.PLog(a.oph, r)) + } + + // Query all gateways in parallel + allAccounts := a.queryAllGateways(ctx, cryptoGateways, req) + + return sresponse.WalletsFromAccounts(a.logger, allAccounts, token) +} + +func filterCryptoGateways(gateways []discovery.GatewaySummary) []discovery.GatewaySummary { + result := make([]discovery.GatewaySummary, 0) + for _, gw := range gateways { + if strings.EqualFold(gw.Rail, cryptoRail) && gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" { + result = append(result, gw) + } + } + return result +} + +func (a *WalletAPI) queryAllGateways(ctx context.Context, gateways []discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) []*connectorv1.Account { + var mu sync.Mutex + var wg sync.WaitGroup + allAccounts := make([]*connectorv1.Account, 0) + + for _, gw := range gateways { + wg.Add(1) + go func(gateway discovery.GatewaySummary) { + defer wg.Done() + + accounts, err := a.queryGateway(ctx, gateway, req) + if err != nil { + a.logger.Warn("Failed to query gateway", + zap.String("gateway_id", gateway.ID), + zap.String("invoke_uri", gateway.InvokeURI), + zap.String("network", gateway.Network), + zap.Error(err)) + return + } + + mu.Lock() + allAccounts = append(allAccounts, accounts...) + mu.Unlock() + + a.logger.Debug("Queried gateway successfully", + zap.String("gateway_id", gateway.ID), + zap.String("network", gateway.Network), + zap.Int("accounts_count", len(accounts))) + }(gw) + } + + wg.Wait() + return allAccounts +} + +func (a *WalletAPI) queryGateway(ctx context.Context, gateway discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) ([]*connectorv1.Account, error) { + // Create connection with timeout + dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout) + defer cancel() + + var dialOpts []grpc.DialOption + if a.insecure { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) + } + + conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...) + if err != nil { + return nil, merrors.InternalWrap(err, "dial gateway") + } + defer conn.Close() + + client := connectorv1.NewConnectorServiceClient(conn) + + // Call with timeout + callCtx, callCancel := context.WithTimeout(ctx, a.callTimeout) + defer callCancel() + + resp, err := client.ListAccounts(callCtx, req) + if err != nil { + return nil, err + } + + return resp.GetAccounts(), nil } diff --git a/api/server/internal/server/walletapiimp/service.go b/api/server/internal/server/walletapiimp/service.go index 3ea6c55c..08f9d4c2 100644 --- a/api/server/internal/server/walletapiimp/service.go +++ b/api/server/internal/server/walletapiimp/service.go @@ -2,60 +2,63 @@ package walletapiimp import ( "context" - "fmt" - "os" - "strings" "time" - chaingatewayclient "github.com/tech/sendico/gateway/chain/client" api "github.com/tech/sendico/pkg/api/http" "github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/db/chainassets" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" eapi "github.com/tech/sendico/server/interface/api" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" ) +const ( + cryptoRail = "CRYPTO" + defaultDialTimeout = 5 * time.Second + defaultCallTimeout = 10 * time.Second + discoveryLookupTimeout = 3 * time.Second +) + type WalletAPI struct { logger mlogger.Logger - chainGateway chainWalletClient + discovery *discovery.Client enf auth.Enforcer oph mutil.ParamHelper wph mutil.ParamHelper walletsPermissionRef primitive.ObjectID balancesPermissionRef primitive.ObjectID assets chainassets.DB -} -type chainWalletClient interface { - CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) - ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) - GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) - Close() error + // Gateway connection settings + dialTimeout time.Duration + callTimeout time.Duration + insecure bool } func (a *WalletAPI) Name() mservice.Type { return mservice.ChainWallets } func (a *WalletAPI) Finish(ctx context.Context) error { - if a.chainGateway != nil { - if err := a.chainGateway.Close(); err != nil { - a.logger.Warn("Failed to close chain gateway client", zap.Error(err)) - } + if a.discovery != nil { + a.discovery.Close() } return nil } func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) { p := &WalletAPI{ - logger: apiCtx.Logger().Named(mservice.Wallets), - enf: apiCtx.Permissions().Enforcer(), - oph: mutil.CreatePH(mservice.Organizations), - wph: mutil.CreatePH(mservice.Wallets), + logger: apiCtx.Logger().Named(mservice.Wallets), + enf: apiCtx.Permissions().Enforcer(), + oph: mutil.CreatePH(mservice.Organizations), + wph: mutil.CreatePH(mservice.Wallets), + dialTimeout: defaultDialTimeout, + callTimeout: defaultCallTimeout, + insecure: true, } var err error @@ -83,9 +86,22 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) { p.logger.Error("Failed to fetch service configuration") return nil, merrors.InvalidArgument("No configuration provided") } - if err := p.initChainGateway(cfg.ChainGateway); err != nil { - p.logger.Error("Failed to initialize chain gateway client", zap.Error(err)) - return nil, err + + // Apply gateway connection settings from config + if gatewayCfg := cfg.ChainGateway; gatewayCfg != nil { + if gatewayCfg.DialTimeoutSeconds > 0 { + p.dialTimeout = time.Duration(gatewayCfg.DialTimeoutSeconds) * time.Second + } + if gatewayCfg.CallTimeoutSeconds > 0 { + p.callTimeout = time.Duration(gatewayCfg.CallTimeoutSeconds) * time.Second + } + p.insecure = gatewayCfg.Insecure + } + + // Initialize discovery client + if err := p.initDiscoveryClient(cfg); err != nil { + p.logger.Warn("Failed to initialize discovery client", zap.Error(err)) + // Not fatal - we can still work without discovery } apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets) @@ -95,31 +111,22 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) { return p, nil } -func (a *WalletAPI) initChainGateway(cfg *eapi.ChainGatewayConfig) error { - if cfg == nil { - return merrors.InvalidArgument("chain gateway configuration is not provided") +func (a *WalletAPI) initDiscoveryClient(cfg *eapi.Config) error { + if cfg == nil || cfg.Mw == nil { + return nil } - - cfg.Address = strings.TrimSpace(cfg.Address) - if cfg.Address == "" { - cfg.Address = strings.TrimSpace(os.Getenv(cfg.AddressEnv)) + msgCfg := cfg.Mw.Messaging + if msgCfg.Driver == "" { + return nil } - if cfg.Address == "" { - return merrors.InvalidArgument(fmt.Sprintf("chain gateway address is not specified and address env %s is empty", cfg.AddressEnv)) - } - - clientCfg := chaingatewayclient.Config{ - Address: cfg.Address, - DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, - CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, - Insecure: cfg.Insecure, - } - - client, err := chaingatewayclient.New(context.Background(), clientCfg) + broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), &msgCfg) if err != nil { return err } - - a.chainGateway = client + client, err := discovery.NewClient(a.logger, broker, nil, string(a.Name())) + if err != nil { + return err + } + a.discovery = client return nil }