diff --git a/api/pkg/db/chainassets/assets.go b/api/pkg/db/chainassets/assets.go new file mode 100644 index 0000000..bdef055 --- /dev/null +++ b/api/pkg/db/chainassets/assets.go @@ -0,0 +1,11 @@ +package chainassets + +import ( + "context" + + "github.com/tech/sendico/pkg/model" +) + +type DB interface { + Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error) +} diff --git a/api/pkg/db/factory.go b/api/pkg/db/factory.go index cc4dfa8..3bf4eb4 100644 --- a/api/pkg/db/factory.go +++ b/api/pkg/db/factory.go @@ -3,6 +3,7 @@ package db import ( "github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/db/account" + "github.com/tech/sendico/pkg/db/chainassets" "github.com/tech/sendico/pkg/db/confirmation" mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo" "github.com/tech/sendico/pkg/db/invitation" @@ -22,6 +23,8 @@ type Factory interface { NewRefreshTokensDB() (refreshtokens.DB, error) NewConfirmationsDB() (confirmation.DB, error) + NewChainAsstesDB() (chainassets.DB, error) + NewAccountDB() (account.DB, error) NewOrganizationDB() (organization.DB, error) NewInvitationsDB() (invitation.DB, error) diff --git a/api/pkg/db/internal/mongo/chainassetsdb/db.go b/api/pkg/db/internal/mongo/chainassetsdb/db.go new file mode 100644 index 0000000..50b5c38 --- /dev/null +++ b/api/pkg/db/internal/mongo/chainassetsdb/db.go @@ -0,0 +1,73 @@ +package chainassetsdb + +import ( + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/db/template" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +type ChainAssetsDB struct { + template.DBImp[*model.ChainAssetDescription] +} + +func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) { + p := &ChainAssetsDB{ + DBImp: *template.Create[*model.ChainAssetDescription](logger, mservice.ChainAssets, db), + } + + // 1) Canonical lookup: enforce single (chain, tokenSymbol) + if err := p.Repository.CreateIndex(&ri.Definition{ + Name: "idx_chain_symbol", + Unique: true, + Keys: []ri.Key{ + {Field: "asset.chain", Sort: ri.Asc}, + {Field: "asset.tokenSymbol", Sort: ri.Asc}, + }, + }); err != nil { + p.Logger.Error("failed index (chain, symbol) unique", zap.Error(err)) + return nil, err + } + + // 2) Prevent duplicate contracts inside the same chain, but only when contract exists + if err := p.Repository.CreateIndex(&ri.Definition{ + Name: "idx_chain_contract_unique", + Unique: true, + Sparse: true, + Keys: []ri.Key{ + {Field: "asset.chain", Sort: ri.Asc}, + {Field: "asset.contractAddress", Sort: ri.Asc}, + }, + }); err != nil { + p.Logger.Error("failed index (chain, contract) unique", zap.Error(err)) + return nil, err + } + + // 3) Fast contract lookup, skip docs without contractAddress (native assets) + if err := p.Repository.CreateIndex(&ri.Definition{ + Name: "idx_contract_lookup", + Sparse: true, + Keys: []ri.Key{ + {Field: "asset.contractAddress", Sort: ri.Asc}, + }, + }); err != nil { + p.Logger.Error("failed index contract lookup", zap.Error(err)) + return nil, err + } + + // 4) List assets per chain + if err := p.Repository.CreateIndex(&ri.Definition{ + Name: "idx_chain_list", + Keys: []ri.Key{ + {Field: "asset.chain", Sort: ri.Asc}, + }, + }); err != nil { + p.Logger.Error("failed index chain list", zap.Error(err)) + return nil, err + } + + return p, nil +} diff --git a/api/pkg/db/internal/mongo/chainassetsdb/resolve.go b/api/pkg/db/internal/mongo/chainassetsdb/resolve.go new file mode 100644 index 0000000..79f5606 --- /dev/null +++ b/api/pkg/db/internal/mongo/chainassetsdb/resolve.go @@ -0,0 +1,18 @@ +package chainassetsdb + +import ( + "context" + + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/model" +) + +func (db *ChainAssetsDB) Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error) { + var assetDescription model.ChainAssetDescription + assetField := repository.Field("asset") + q := repository.Query().And( + repository.Query().Filter(assetField.Dot("chain"), chainAsset.Chain), + repository.Query().Filter(assetField.Dot("tokenSymbol"), chainAsset.TokenSymbol), + ) + return &assetDescription, db.DBImp.FindOne(ctx, q, &assetDescription) +} diff --git a/api/pkg/db/internal/mongo/db.go b/api/pkg/db/internal/mongo/db.go index 3e4e2f6..ceeba72 100755 --- a/api/pkg/db/internal/mongo/db.go +++ b/api/pkg/db/internal/mongo/db.go @@ -10,8 +10,10 @@ import ( "github.com/mitchellh/mapstructure" "github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/db/account" + "github.com/tech/sendico/pkg/db/chainassets" "github.com/tech/sendico/pkg/db/confirmation" "github.com/tech/sendico/pkg/db/internal/mongo/accountdb" + "github.com/tech/sendico/pkg/db/internal/mongo/chainassetsdb" "github.com/tech/sendico/pkg/db/internal/mongo/confirmationdb" "github.com/tech/sendico/pkg/db/internal/mongo/invitationdb" "github.com/tech/sendico/pkg/db/internal/mongo/organizationdb" @@ -312,6 +314,10 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host return hosts } +func (db *DB) NewChainAsstesDB() (chainassets.DB, error) { + return chainassetsdb.Create(db.logger, db.db()) +} + func (db *DB) Permissions() auth.Provider { return db } diff --git a/api/pkg/db/internal/mongo/repositoryimp/index.go b/api/pkg/db/internal/mongo/repositoryimp/index.go index 3df08bd..50ac3bf 100644 --- a/api/pkg/db/internal/mongo/repositoryimp/index.go +++ b/api/pkg/db/internal/mongo/repositoryimp/index.go @@ -44,6 +44,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error { if def.PartialFilter != nil { opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery()) } + if def.Sparse { + opts.SetSparse(def.Sparse) + } _, err := r.collection.Indexes().CreateOne( context.Background(), diff --git a/api/pkg/db/repository/index/index.go b/api/pkg/db/repository/index/index.go index 06e7122..a689631 100644 --- a/api/pkg/db/repository/index/index.go +++ b/api/pkg/db/repository/index/index.go @@ -18,6 +18,7 @@ type Key struct { type Definition struct { Keys []Key // mandatory, at least one element Unique bool // unique constraint? + Sparse bool // sparse? TTL *int32 // seconds; nil means “no TTL” Name string // optional explicit name PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes diff --git a/api/pkg/model/chainasset.go b/api/pkg/model/chainasset.go new file mode 100644 index 0000000..f98b1b4 --- /dev/null +++ b/api/pkg/model/chainasset.go @@ -0,0 +1,26 @@ +package model + +import ( + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" +) + +type ChainAssetKey struct { + Chain ChainNetwork `bson:"chain" json:"chain" yaml:"chain" mapstructure:"chain"` + TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol" yaml:"tokenSymbol" mapstructure:"tokenSymbol"` +} + +type ChainAsset struct { + ChainAssetKey `bson:",inline" json:",inline"` + ContractAddress *string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` +} + +type ChainAssetDescription struct { + storable.Storable `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + Asset ChainAsset `bson:"asset" json:"asset"` +} + +func Collection(*ChainAssetDescription) mservice.Type { + return mservice.ChainAssets +} diff --git a/api/pkg/model/chains.go b/api/pkg/model/chains.go new file mode 100644 index 0000000..000d9d0 --- /dev/null +++ b/api/pkg/model/chains.go @@ -0,0 +1,11 @@ +package model + +type ChainNetwork string + +const ( + ChainNetworkARB ChainNetwork = "arbitrum_one" + ChainNetworkEthMain ChainNetwork = "ethereum_mainnet" + ChainNetworkTronMain ChainNetwork = "tron_mainnet" + ChainNetworkTronNile ChainNetwork = "tron_nile" + ChainNetworkUnspecified ChainNetwork = "unspecified" +) diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 49d882c..ed6272d 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -5,50 +5,51 @@ import "github.com/tech/sendico/pkg/merrors" type Type = string const ( - Accounts Type = "accounts" // Represents user accounts in the system - Confirmations Type = "confirmations" // Represents confirmation code flows - Amplitude Type = "amplitude" // Represents analytics integration with Amplitude - Discovery Type = "discovery" // Represents service discovery registry - Site Type = "site" // Represents public site endpoints - Changes Type = "changes" // Tracks changes made to resources - Clients Type = "clients" // Represents client information - ChainGateway Type = "chain_gateway" // Represents chain gateway microservice - MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice - PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice - FXOracle Type = "fx_oracle" // Represents FX oracle microservice - FeePlans Type = "fee_plans" // Represents fee plans microservice - FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources - Invitations Type = "invitations" // Represents invitations sent to users - Invoices Type = "invoices" // Represents invoices - Logo Type = "logo" // Represents logos for organizations or projects - Ledger Type = "ledger" // Represents ledger microservice - LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice - LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice - LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice - LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice - LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice - LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice - PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice - ChainWallets Type = "chain_wallets" // Represents managed chain wallets - ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances - ChainTransfers Type = "chain_transfers" // Represents chain transfers - ChainDeposits Type = "chain_deposits" // Represents chain deposits - Notifications Type = "notifications" // Represents notifications sent to users - Organizations Type = "organizations" // Represents organizations in the system - Payments Type = "payments" // Represents payments service - PaymentRoutes Type = "payment_routes" // Represents payment routing definitions + Accounts Type = "accounts" // Represents user accounts in the system + Confirmations Type = "confirmations" // Represents confirmation code flows + Amplitude Type = "amplitude" // Represents analytics integration with Amplitude + Discovery Type = "discovery" // Represents service discovery registry + Site Type = "site" // Represents public site endpoints + Changes Type = "changes" // Tracks changes made to resources + Clients Type = "clients" // Represents client information + ChainGateway Type = "chain_gateway" // Represents chain gateway microservice + MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice + PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice + FXOracle Type = "fx_oracle" // Represents FX oracle microservice + FeePlans Type = "fee_plans" // Represents fee plans microservice + FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources + Invitations Type = "invitations" // Represents invitations sent to users + Invoices Type = "invoices" // Represents invoices + Logo Type = "logo" // Represents logos for organizations or projects + Ledger Type = "ledger" // Represents ledger microservice + LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice + LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice + LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice + LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice + LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice + LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice + PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice + ChainAssets Type = "chain_assets" // Represents managed chain assets + ChainWallets Type = "chain_wallets" // Represents managed chain wallets + ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances + ChainTransfers Type = "chain_transfers" // Represents chain transfers + ChainDeposits Type = "chain_deposits" // Represents chain deposits + Notifications Type = "notifications" // Represents notifications sent to users + Organizations Type = "organizations" // Represents organizations in the system + Payments Type = "payments" // Represents payments service + PaymentRoutes Type = "payment_routes" // Represents payment routing definitions PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates - PaymentMethods Type = "payment_methods" // Represents payment methods service - Permissions Type = "permissions" // Represents permissiosns service - Policies Type = "policies" // Represents access control policies - PolicyAssignements Type = "policy_assignments" // Represents policy assignments database - Recipients Type = "recipients" // Represents payment recipients - RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication - Roles Type = "roles" // Represents roles in access control - Storage Type = "storage" // Represents statuses of tasks or projects - Tenants Type = "tenants" // Represents tenants managed in the system - Wallets Type = "wallets" // Represents workflows for tasks or projects - Workflows Type = "workflows" // Represents workflows for tasks or projects + PaymentMethods Type = "payment_methods" // Represents payment methods service + Permissions Type = "permissions" // Represents permissiosns service + Policies Type = "policies" // Represents access control policies + PolicyAssignements Type = "policy_assignments" // Represents policy assignments database + Recipients Type = "recipients" // Represents payment recipients + RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication + Roles Type = "roles" // Represents roles in access control + Storage Type = "storage" // Represents statuses of tasks or projects + Tenants Type = "tenants" // Represents tenants managed in the system + Wallets Type = "wallets" // Represents workflows for tasks or projects + Workflows Type = "workflows" // Represents workflows for tasks or projects ) func StringToSType(s string) (Type, error) { diff --git a/api/proto/connector/v1/connector.proto b/api/proto/connector/v1/connector.proto index 98deb15..438bf27 100644 --- a/api/proto/connector/v1/connector.proto +++ b/api/proto/connector/v1/connector.proto @@ -128,7 +128,7 @@ message Account { string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20) AccountState state = 4; string label = 5; - string owner_ref = 6; + string owner_ref = 6; // optional account_ref; empty means organization-owned google.protobuf.Struct provider_details = 7; google.protobuf.Timestamp created_at = 8; google.protobuf.Timestamp updated_at = 9; @@ -188,7 +188,7 @@ message OpenAccountRequest { AccountKind kind = 2; string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20) string label = 4; - string owner_ref = 5; + string owner_ref = 5; // optional account_ref; empty means organization-owned google.protobuf.Struct params = 6; string correlation_id = 7; string parent_intent_id = 8; @@ -212,6 +212,7 @@ message ListAccountsRequest { 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) } message ListAccountsResponse { diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index b848989..f5fc159 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -70,14 +70,15 @@ message PostingLine { message CreateAccountRequest { string organization_ref = 1; - string account_code = 2; - AccountType account_type = 3; - string currency = 4; - AccountStatus status = 5; - bool allow_negative = 6; - bool is_settlement = 7; - map metadata = 8; - common.describable.v1.Describable describable = 9; + string owner_ref = 2; + string account_code = 3; + AccountType account_type = 4; + string currency = 5; + AccountStatus status = 6; + bool allow_negative = 7; + bool is_settlement = 8; + map metadata = 9; + common.describable.v1.Describable describable = 10; } message CreateAccountResponse { diff --git a/api/server/interface/api/srequest/ledger.go b/api/server/interface/api/srequest/ledger.go index 4aee144..24585d6 100644 --- a/api/server/interface/api/srequest/ledger.go +++ b/api/server/interface/api/srequest/ledger.go @@ -33,7 +33,8 @@ type CreateLedgerAccount struct { AllowNegative bool `json:"allowNegative,omitempty"` IsSettlement bool `json:"isSettlement,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` - Describable *model.Describable `json:"describable,omitempty"` + Describable model.Describable `json:"describable"` + IsOrgWallet bool `json:"isOrgWallet"` } func (r *CreateLedgerAccount) Validate() error { diff --git a/api/server/interface/api/srequest/wallet.go b/api/server/interface/api/srequest/wallet.go new file mode 100644 index 0000000..dd9390e --- /dev/null +++ b/api/server/interface/api/srequest/wallet.go @@ -0,0 +1,9 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type CreateWallet struct { + Description model.Describable `json:"description"` + IsOrgWallet bool `json:"isOrgWallet"` + Asset model.ChainAssetKey `json:"asset"` +} diff --git a/api/server/internal/mutil/proto/chain.go b/api/server/internal/mutil/proto/chain.go new file mode 100644 index 0000000..2a01a73 --- /dev/null +++ b/api/server/internal/mutil/proto/chain.go @@ -0,0 +1,45 @@ +package proto + +import ( + "fmt" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +func Network2Proto(network model.ChainNetwork) (chainv1.ChainNetwork, error) { + switch network { + case model.ChainNetworkARB: + return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil + case model.ChainNetworkEthMain: + return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil + case model.ChainNetworkTronMain: + 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") + } +} + +func Asset2Proto(asset *model.ChainAsset) (*chainv1.Asset, error) { + if asset == nil { + return nil, merrors.InvalidArgument("Asset must be provided", "asset") + } + netw, err := Network2Proto(asset.Chain) + if err != nil { + return nil, err + } + var contract string + if asset.ContractAddress != nil { + contract = *asset.ContractAddress + } + return &chainv1.Asset{ + Chain: netw, + TokenSymbol: asset.TokenSymbol, + ContractAddress: contract, + }, nil +} diff --git a/api/server/internal/server/accountapiimp/signup.go b/api/server/internal/server/accountapiimp/signup.go index 2f707cf..c885ce7 100644 --- a/api/server/internal/server/accountapiimp/signup.go +++ b/api/server/internal/server/accountapiimp/signup.go @@ -70,7 +70,7 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc { var sr srequest.Signup if err := json.NewDecoder(r.Body).Decode(&sr); err != nil { a.logger.Warn("Failed to decode signup request", zap.Error(err)) - return response.BadRequest(a.logger, a.Name(), "", err.Error()) + return response.BadPayload(a.logger, a.Name(), err) } sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login)) @@ -252,12 +252,11 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization, req := &chainv1.CreateManagedWalletRequest{ IdempotencyKey: uuid.NewString(), OrganizationRef: org.ID.Hex(), - OwnerRef: org.ID.Hex(), Describable: &describablev1.Describable{ Name: sr.CryptoWallet.Name, Description: sr.CryptoWallet.Description, }, - Asset: a.chainAsset, + Asset: a.chainAsset, Metadata: map[string]string{ "source": "signup", "login": sr.Account.Login, diff --git a/api/server/internal/server/ledgerapiimp/create.go b/api/server/internal/server/ledgerapiimp/create.go index d546b68..273d232 100644 --- a/api/server/internal/server/ledgerapiimp/create.go +++ b/api/server/internal/server/ledgerapiimp/create.go @@ -56,25 +56,28 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token } var describable *describablev1.Describable - if payload.Describable != nil { - name := strings.TrimSpace(payload.Describable.Name) - var description *string - if payload.Describable.Description != nil { - trimmed := strings.TrimSpace(*payload.Describable.Description) - if trimmed != "" { - description = &trimmed - } + name := strings.TrimSpace(payload.Describable.Name) + var description *string + if payload.Describable.Description != nil { + trimmed := strings.TrimSpace(*payload.Describable.Description) + if trimmed != "" { + description = &trimmed } - if name != "" || description != nil { - describable = &describablev1.Describable{ - Name: name, - Description: description, - } + } + if name != "" || description != nil { + describable = &describablev1.Describable{ + Name: name, + Description: description, } } + var ownerRef string + if !payload.IsOrgWallet { + ownerRef = account.ID.Hex() + } resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{ OrganizationRef: orgRef.Hex(), + OwnerRef: ownerRef, AccountCode: payload.AccountCode, AccountType: accountType, Currency: payload.Currency, @@ -95,24 +98,19 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAccount, error) { defer r.Body.Close() - payload := &srequest.CreateLedgerAccount{} - if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + payload := srequest.CreateLedgerAccount{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) } payload.AccountCode = strings.TrimSpace(payload.AccountCode) payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency)) - if payload.Describable != nil { - payload.Describable.Name = strings.TrimSpace(payload.Describable.Name) - if payload.Describable.Description != nil { - trimmed := strings.TrimSpace(*payload.Describable.Description) - if trimmed == "" { - payload.Describable.Description = nil - } else { - payload.Describable.Description = &trimmed - } - } - if payload.Describable.Name == "" && payload.Describable.Description == nil { - payload.Describable = nil + payload.Describable.Name = strings.TrimSpace(payload.Describable.Name) + if payload.Describable.Description != nil { + trimmed := strings.TrimSpace(*payload.Describable.Description) + if trimmed == "" { + payload.Describable.Description = nil + } else { + payload.Describable.Description = &trimmed } } if len(payload.Metadata) == 0 { @@ -121,7 +119,7 @@ func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAc if err := payload.Validate(); err != nil { return nil, err } - return payload, nil + return &payload, nil } func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) { diff --git a/api/server/internal/server/walletapiimp/create.go b/api/server/internal/server/walletapiimp/create.go new file mode 100644 index 0000000..469686b --- /dev/null +++ b/api/server/internal/server/walletapiimp/create.go @@ -0,0 +1,98 @@ +package walletapiimp + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/tech/sendico/pkg/api/http/response" + "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" + "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" +) + +func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for wallet list", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r))) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + var sr srequest.CreateWallet + if err := json.NewDecoder(r.Body).Decode(&sr); err != nil { + a.logger.Warn("Failed to decode wallet creation request request", zap.Error(err), mzap.StorableRef(account)) + return response.BadPayload(a.logger, a.Name(), err) + } + + ctx := r.Context() + res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate) + if err != nil { + a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r), mzap.StorableRef(account)) + return response.Auto(a.logger, a.Name(), err) + } + if !res { + a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r), mzap.StorableRef(account)) + return response.AccessDenied(a.logger, a.Name(), "wallets creation permission denied") + } + + asset, err := a.assets.Resolve(ctx, sr.Asset) + if err != nil { + a.logger.Warn("Failed to resolve asset", zap.Error(err), mzap.StorableRef(account), + zap.String("chain", string(sr.Asset.Chain)), zap.String("token", sr.Asset.TokenSymbol)) + 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")) + } + + var ownerRef string + if !sr.IsOrgWallet { + ownerRef = account.ID.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) + } + + 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, + }, + } + + resp, err := a.chainGateway.CreateManagedWallet(ctx, req) + if err != nil { + a.logger.Warn("Failed to create managed wallet", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account)) + 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)) + + return sresponse.Success(a.logger, token) +} diff --git a/api/server/internal/server/walletapiimp/service.go b/api/server/internal/server/walletapiimp/service.go index 893c33d..3ea6c55 100644 --- a/api/server/internal/server/walletapiimp/service.go +++ b/api/server/internal/server/walletapiimp/service.go @@ -10,6 +10,7 @@ import ( 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/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" @@ -28,9 +29,11 @@ type WalletAPI struct { 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 @@ -55,6 +58,12 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) { wph: mutil.CreatePH(mservice.Wallets), } + var err error + if p.assets, err = apiCtx.DBFactory().NewChainAsstesDB(); err != nil { + p.logger.Warn("Failed to create asstes db", zap.Error(err)) + return nil, err + } + walletsPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWallets) if err != nil { p.logger.Warn("Failed to fetch chain wallets permission policy description", zap.Error(err)) @@ -81,6 +90,7 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) { apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets) apiCtx.Register().AccountHandler(p.Name(), p.wph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getWalletBalance) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.create) return p, nil }