diff --git a/internal/data/wallets.go b/internal/data/wallets.go index bdd1fbbfd..84e3cce04 100644 --- a/internal/data/wallets.go +++ b/internal/data/wallets.go @@ -40,6 +40,7 @@ type WalletInsert struct { Homepage string `db:"homepage"` SEP10ClientDomain string `db:"sep_10_client_domain"` DeepLinkSchema string `db:"deep_link_schema"` + Enabled bool `db:"enabled"` AssetsIDs []string `db:"assets_ids"` } @@ -163,13 +164,13 @@ func (wm *WalletModel) Insert(ctx context.Context, newWallet WalletInsert) (*Wal const query = ` WITH new_wallet AS ( INSERT INTO wallets - (name, homepage, deep_link_schema, sep_10_client_domain) + (name, homepage, deep_link_schema, sep_10_client_domain, enabled) VALUES - ($1, $2, $3, $4) + ($1, $2, $3, $4, $5) RETURNING * ), assets_cte AS ( - SELECT UNNEST($5::text[]) id + SELECT UNNEST($6::text[]) id ), new_wallet_assets AS ( INSERT INTO wallets_assets (wallet_id, asset_id) @@ -183,12 +184,11 @@ func (wm *WalletModel) Insert(ctx context.Context, newWallet WalletInsert) (*Wal ` var w Wallet - err := dbTx.GetContext( + if err := dbTx.GetContext( ctx, &w, query, - newWallet.Name, newWallet.Homepage, newWallet.DeepLinkSchema, newWallet.SEP10ClientDomain, + newWallet.Name, newWallet.Homepage, newWallet.DeepLinkSchema, newWallet.SEP10ClientDomain, newWallet.Enabled, pq.Array(newWallet.AssetsIDs), - ) - if err != nil { + ); err != nil { if pqError, ok := err.(*pq.Error); ok { constraintErrMap := map[string]error{ "wallets_assets_asset_id_fkey": ErrInvalidAssetID, diff --git a/internal/serve/httphandler/wallets_handler.go b/internal/serve/httphandler/wallets_handler.go index 7721f524b..7433a3b87 100644 --- a/internal/serve/httphandler/wallets_handler.go +++ b/internal/serve/httphandler/wallets_handler.go @@ -1,6 +1,7 @@ package httphandler import ( + "context" "errors" "fmt" "net/http" @@ -12,12 +13,14 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type WalletsHandler struct { - Models *data.Models - NetworkType utils.NetworkType + Models *data.Models + NetworkType utils.NetworkType + AssetResolver *services.AssetResolver } // GetWallets returns a list of wallets @@ -74,30 +77,36 @@ func (h WalletsHandler) PostWallets(rw http.ResponseWriter, req *http.Request) { return } - wallet, err := h.Models.Wallets.Insert(ctx, data.WalletInsert{ + // Resolve asset references to IDs + var assetIDs []string + var err error + + if len(reqBody.Assets) > 0 { + assetIDs, err = h.AssetResolver.ResolveAssetReferences(ctx, reqBody.Assets) + if err != nil { + httperror.BadRequest("failed to resolve asset references", err, nil).Render(rw) + return + } + } else if len(reqBody.AssetsIDs) > 0 { + if err = h.AssetResolver.ValidateAssetIDs(ctx, reqBody.AssetsIDs); err != nil { + httperror.BadRequest("invalid asset ID", err, nil).Render(rw) + return + } + assetIDs = reqBody.AssetsIDs + } + + walletInsert := data.WalletInsert{ Name: reqBody.Name, Homepage: reqBody.Homepage, SEP10ClientDomain: reqBody.SEP10ClientDomain, DeepLinkSchema: reqBody.DeepLinkSchema, - AssetsIDs: reqBody.AssetsIDs, - }) - if err != nil { - switch { - case errors.Is(err, data.ErrInvalidAssetID): - httperror.BadRequest(data.ErrInvalidAssetID.Error(), err, nil).Render(rw) - return - case errors.Is(err, data.ErrWalletNameAlreadyExists): - httperror.Conflict(data.ErrWalletNameAlreadyExists.Error(), err, nil).Render(rw) - return - case errors.Is(err, data.ErrWalletHomepageAlreadyExists): - httperror.Conflict(data.ErrWalletHomepageAlreadyExists.Error(), err, nil).Render(rw) - return - case errors.Is(err, data.ErrWalletDeepLinkSchemaAlreadyExists): - httperror.Conflict(data.ErrWalletDeepLinkSchemaAlreadyExists.Error(), err, nil).Render(rw) - return - } + AssetsIDs: assetIDs, + Enabled: *reqBody.Enabled, + } - httperror.InternalError(ctx, "", err, nil).Render(rw) + wallet, err := h.Models.Wallets.Insert(ctx, walletInsert) + if err != nil { + h.handleWalletCreationError(ctx, rw, err) return } @@ -159,3 +168,18 @@ func (h WalletsHandler) PatchWallets(rw http.ResponseWriter, req *http.Request) httpjson.Render(rw, map[string]string{"message": "wallet updated successfully"}, httpjson.JSON) } + +func (h WalletsHandler) handleWalletCreationError(ctx context.Context, rw http.ResponseWriter, err error) { + switch { + case errors.Is(err, data.ErrInvalidAssetID): + httperror.BadRequest(data.ErrInvalidAssetID.Error(), err, nil).Render(rw) + case errors.Is(err, data.ErrWalletNameAlreadyExists): + httperror.Conflict(data.ErrWalletNameAlreadyExists.Error(), err, nil).Render(rw) + case errors.Is(err, data.ErrWalletHomepageAlreadyExists): + httperror.Conflict(data.ErrWalletHomepageAlreadyExists.Error(), err, nil).Render(rw) + case errors.Is(err, data.ErrWalletDeepLinkSchemaAlreadyExists): + httperror.Conflict(data.ErrWalletDeepLinkSchemaAlreadyExists.Error(), err, nil).Render(rw) + default: + httperror.InternalError(ctx, "failed to create wallet", err, nil).Render(rw) + } +} diff --git a/internal/serve/httphandler/wallets_handler_test.go b/internal/serve/httphandler/wallets_handler_test.go index ccbcd3851..632cb103d 100644 --- a/internal/serve/httphandler/wallets_handler_test.go +++ b/internal/serve/httphandler/wallets_handler_test.go @@ -18,15 +18,13 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) func Test_WalletsHandlerGetWallets(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, outerErr) - defer dbConnectionPool.Close() + dbConnectionPool := getDBConnectionPool(t) models, outerErr := data.NewModels(dbConnectionPool) require.NoError(t, outerErr) @@ -172,17 +170,14 @@ func Test_WalletsHandlerGetWallets(t *testing.T) { } func Test_WalletsHandlerPostWallets(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() + dbConnectionPool := getDBConnectionPool(t) models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) ctx := context.Background() - handler := &WalletsHandler{Models: models} + assetResolver := services.NewAssetResolver(models.Assets) + handler := &WalletsHandler{Models: models, AssetResolver: assetResolver} // Fixture setup wallet := data.ClearAndCreateWalletFixtures(t, ctx, dbConnectionPool)[0] @@ -212,7 +207,7 @@ func Test_WalletsHandlerPostWallets(t *testing.T) { "homepage": "homepage is required", "deep_link_schema": "deep_link_schema is required", "sep_10_client_domain": "sep_10_client_domain is required", - "assets_ids": "provide at least one asset ID" + "assets": "provide at least one 'assets_ids' or 'assets'" } }`, }, @@ -228,7 +223,7 @@ func Test_WalletsHandlerPostWallets(t *testing.T) { expectedBody: `{ "error": "invalid request body", "extras": { - "assets_ids": "provide at least one asset ID" + "assets": "provide at least one 'assets_ids' or 'assets'" } }`, }, @@ -344,6 +339,235 @@ func Test_WalletsHandlerPostWallets(t *testing.T) { } } +func Test_WalletsHandlerPostWallets_WithNewAssetFormat(t *testing.T) { + dbConnectionPool := getDBConnectionPool(t) + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + ctx := context.Background() + + assetResolver := services.NewAssetResolver(models.Assets) + + handler := &WalletsHandler{ + Models: models, + NetworkType: utils.PubnetNetworkType, + AssetResolver: assetResolver, + } + + xlm := data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.XLMAssetCode, "") + usdc, err := models.Assets.GetOrCreate(ctx, assets.USDCAssetCode, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5") + require.NoError(t, err) + + testCases := []struct { + name string + payload string + expectedStatus int + expectedBody string + validateResult func(t *testing.T, wallet *data.Wallet) + }{ + { + name: "🟢 successfully creates wallet with new assets format - ID reference", + payload: fmt.Sprintf(`{ + "name": "New Format Wallet ID", + "homepage": "https://newformat-id.com", + "deep_link_schema": "newformat-id://sdp", + "sep_10_client_domain": "newformat-id.com", + "assets": [ + {"id": %q}, + {"id": %q} + ] + }`, xlm.ID, usdc.ID), + expectedStatus: http.StatusCreated, + validateResult: func(t *testing.T, wallet *data.Wallet) { + assert.Equal(t, "New Format Wallet ID", wallet.Name) + assert.Len(t, wallet.Assets, 2) + + assetCodes := []string{wallet.Assets[0].Code, wallet.Assets[1].Code} + assert.Contains(t, assetCodes, assets.XLMAssetCode) + assert.Contains(t, assetCodes, assets.USDCAssetCode) + }, + }, + { + name: "🟢 successfully creates wallet with native asset reference", + payload: `{ + "name": "Native Asset Wallet", + "homepage": "https://native-wallet.com", + "deep_link_schema": "native://sdp", + "sep_10_client_domain": "native-wallet.com", + "assets": [ + {"type": "native"} + ] + }`, + expectedStatus: http.StatusCreated, + validateResult: func(t *testing.T, wallet *data.Wallet) { + assert.Len(t, wallet.Assets, 1) + assert.Equal(t, assets.XLMAssetCode, wallet.Assets[0].Code) + assert.Equal(t, "", wallet.Assets[0].Issuer) + }, + }, + { + name: "🟢 successfully creates wallet with classic asset reference", + payload: `{ + "name": "Classic Asset Wallet", + "homepage": "https://classic-wallet.com", + "deep_link_schema": "classic://sdp", + "sep_10_client_domain": "classic-wallet.com", + "assets": [ + { + "type": "classic", + "code": "USDC", + "issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + } + ] + }`, + expectedStatus: http.StatusCreated, + validateResult: func(t *testing.T, wallet *data.Wallet) { + assert.Len(t, wallet.Assets, 1) + assert.Equal(t, assets.USDCAssetCode, wallet.Assets[0].Code) + assert.Equal(t, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", wallet.Assets[0].Issuer) + }, + }, + { + name: "🟢 successfully creates wallet with mixed asset references", + payload: fmt.Sprintf(`{ + "name": "Mixed Assets Wallet", + "homepage": "https://mixed-wallet.com", + "deep_link_schema": "mixed://sdp", + "sep_10_client_domain": "mixed-wallet.com", + "assets": [ + {"id": %q}, + {"type": "native"} + ] + }`, usdc.ID), + expectedStatus: http.StatusCreated, + validateResult: func(t *testing.T, wallet *data.Wallet) { + assert.Len(t, wallet.Assets, 2) + + assetMap := make(map[string]string) + for _, asset := range wallet.Assets { + assetMap[asset.Code] = asset.Issuer + } + + assert.Equal(t, "", assetMap[assets.XLMAssetCode]) + assert.Equal(t, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", assetMap["USDC"]) + }, + }, + { + name: "🟢 successfully creates wallet with enabled=false", + payload: fmt.Sprintf(`{ + "name": "Disabled Wallet", + "homepage": "https://disabled-wallet.com", + "deep_link_schema": "disabled://sdp", + "sep_10_client_domain": "disabled-wallet.com", + "assets": [{"id": %q}], + "enabled": false + }`, xlm.ID), + expectedStatus: http.StatusCreated, + validateResult: func(t *testing.T, wallet *data.Wallet) { + assert.False(t, wallet.Enabled) + }, + }, + { + name: "🔴 fails when mixing assets_ids and assets", + payload: fmt.Sprintf(`{ + "name": "Mixed Format Wallet", + "homepage": "https://mixed-format.com", + "deep_link_schema": "mixed-format://sdp", + "sep_10_client_domain": "mixed-format.com", + "assets_ids": [%q], + "assets": [{"type": "native"}] + }`, xlm.ID), + expectedStatus: http.StatusBadRequest, + expectedBody: `{ + "error": "invalid request body", + "extras": { + "assets": "cannot use both 'assets_ids' and 'assets' fields simultaneously" + } + }`, + }, + { + name: "🔴 fails with invalid asset reference", + payload: `{ + "name": "Invalid Asset Wallet", + "homepage": "https://invalid-asset.com", + "deep_link_schema": "invalid-asset://sdp", + "sep_10_client_domain": "invalid-asset.com", + "assets": [ + {"type": "classic", "code": "MISSING_ISSUER"} + ] + }`, + expectedStatus: http.StatusBadRequest, + expectedBody: `{ + "error": "invalid request body", + "extras": { + "assets[0]": "'issuer' is required for classic asset" + } + }`, + }, + { + name: "🔴 fails with non-existent asset ID", + payload: `{ + "name": "Non-existent Asset Wallet", + "homepage": "https://nonexistent.com", + "deep_link_schema": "nonexistent://sdp", + "sep_10_client_domain": "nonexistent.com", + "assets": [ + {"id": "non-existent-id"} + ] + }`, + expectedStatus: http.StatusBadRequest, + expectedBody: `{"error": "failed to resolve asset references"}`, + }, + { + name: "🔴 fails with contract asset (not implemented)", + payload: `{ + "name": "Contract Asset Wallet", + "homepage": "https://contract.com", + "deep_link_schema": "contract://sdp", + "sep_10_client_domain": "contract.com", + "assets": [ + {"type": "contract", "code": "USDC", "contract_id": "CA..."} + ] + }`, + expectedStatus: http.StatusBadRequest, + expectedBody: `{ + "error": "invalid request body", + "extras": { + "assets[0]": "assets are not implemented yet" + } + }`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rr := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/wallets", strings.NewReader(tc.payload)) + require.NoError(t, err) + + http.HandlerFunc(handler.PostWallets).ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, tc.expectedStatus, resp.StatusCode) + + if tc.expectedBody != "" { + assert.JSONEq(t, tc.expectedBody, string(respBody)) + } else if tc.expectedStatus == http.StatusCreated && tc.validateResult != nil { + var wallet data.Wallet + err = json.Unmarshal(respBody, &wallet) + require.NoError(t, err) + + tc.validateResult(t, &wallet) + } + }) + } +} + func Test_WalletsHandlerDeleteWallet(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() diff --git a/internal/serve/serve.go b/internal/serve/serve.go index ec5336e91..c4d8ccb0d 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -374,8 +374,9 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.With(middleware.AnyRoleMiddleware(authManager, data.GetAllRoles()...)).Route("/wallets", func(r chi.Router) { walletsHandler := httphandler.WalletsHandler{ - Models: o.Models, - NetworkType: o.NetworkType, + Models: o.Models, + NetworkType: o.NetworkType, + AssetResolver: services.NewAssetResolver(o.Models.Assets), } r.Get("/", walletsHandler.GetWallets) r.With(middleware.AnyRoleMiddleware(authManager, data.DeveloperUserRole)). diff --git a/internal/serve/validators/wallet_validator.go b/internal/serve/validators/wallet_validator.go index dcd34239d..20f5d0651 100644 --- a/internal/serve/validators/wallet_validator.go +++ b/internal/serve/validators/wallet_validator.go @@ -2,20 +2,45 @@ package validators import ( "context" + "fmt" "net/url" "strings" + "github.com/stellar/go/strkey" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) +type AssetReferenceType string + +const ( + AssetReferenceTypeID AssetReferenceType = "id" + AssetReferenceTypeClassic AssetReferenceType = "classic" + AssetReferenceTypeNative AssetReferenceType = "native" + AssetReferenceTypeContract AssetReferenceType = "contract" + AssetReferenceTypeFiat AssetReferenceType = "fiat" +) + +type AssetReference struct { + // For ID-based reference + ID string `json:"id,omitempty"` + + Type string `json:"type,omitempty"` + Code string `json:"code,omitempty"` + Issuer string `json:"issuer,omitempty"` + ContractID string `json:"contract_id,omitempty"` +} + type WalletRequest struct { - Name string `json:"name"` - Homepage string `json:"homepage"` - DeepLinkSchema string `json:"deep_link_schema"` - SEP10ClientDomain string `json:"sep_10_client_domain"` - AssetsIDs []string `json:"assets_ids"` + Name string `json:"name"` + Homepage string `json:"homepage"` + DeepLinkSchema string `json:"deep_link_schema"` + SEP10ClientDomain string `json:"sep_10_client_domain"` + Enabled *bool `json:"enabled,omitempty"` + Assets []AssetReference `json:"assets,omitempty"` + AssetsIDs []string `json:"assets_ids,omitempty"` // Legacy support } type PatchWalletRequest struct { @@ -47,7 +72,20 @@ func (wv *WalletValidator) ValidateCreateWalletRequest(ctx context.Context, reqB wv.Check(homepage != "", "homepage", "homepage is required") wv.Check(deepLinkSchema != "", "deep_link_schema", "deep_link_schema is required") wv.Check(sep10ClientDomain != "", "sep_10_client_domain", "sep_10_client_domain is required") - wv.Check(len(reqBody.AssetsIDs) != 0, "assets_ids", "provide at least one asset ID") + wv.Check(len(reqBody.AssetsIDs) != 0 || len(reqBody.Assets) != 0, "assets", "provide at least one 'assets_ids' or 'assets'") + wv.Check(len(reqBody.AssetsIDs) == 0 || len(reqBody.Assets) == 0, "assets", "cannot use both 'assets_ids' and 'assets' fields simultaneously") + var processedAssets []AssetReference + if len(reqBody.Assets) != 0 { + for i, asset := range reqBody.Assets { + inferredAsset := wv.inferAssetType(asset) + if err := inferredAsset.Validate(); err != nil { + wv.Check(false, fmt.Sprintf("assets[%d]", i), err.Error()) + continue + } + processedAssets = append(processedAssets, inferredAsset) + } + } + if wv.HasErrors() { return nil } @@ -86,6 +124,11 @@ func (wv *WalletValidator) ValidateCreateWalletRequest(ctx context.Context, reqB wv.Check(false, "sep_10_client_domain", "invalid SEP-10 client domain provided") } + if reqBody.Enabled == nil { + defEnabled := true + reqBody.Enabled = &defEnabled + } + if wv.HasErrors() { return nil } @@ -95,12 +138,43 @@ func (wv *WalletValidator) ValidateCreateWalletRequest(ctx context.Context, reqB Homepage: homepageURL.String(), DeepLinkSchema: deepLinkSchemaURL.String(), SEP10ClientDomain: sep10Host, + Assets: processedAssets, AssetsIDs: reqBody.AssetsIDs, + Enabled: reqBody.Enabled, } return modifiedReq } +func (wv *WalletValidator) inferAssetType(asset AssetReference) AssetReference { + // If ID is provided, no inference needed + if asset.ID != "" { + return asset + } + + // If type is already specified, no inference needed + if asset.Type != "" { + return asset + } + + // Inference logic for backward compatibility + result := asset + + if strings.ToUpper(asset.Code) == assets.XLMAssetCode && asset.Issuer == "" { + result.Type = string(AssetReferenceTypeNative) + result.Code = "" + return result + } + + // Classic asset detection: has both code and issuer + if asset.Code != "" && asset.Issuer != "" { + result.Type = string(AssetReferenceTypeClassic) + return result + } + + return result +} + func (wv *WalletValidator) ValidatePatchWalletRequest(reqBody *PatchWalletRequest) { wv.Check(reqBody != nil, "body", "request body is empty") if wv.HasErrors() { @@ -108,3 +182,47 @@ func (wv *WalletValidator) ValidatePatchWalletRequest(reqBody *PatchWalletReques } wv.Check(reqBody.Enabled != nil, "enabled", "enabled is required") } + +func (ar AssetReference) Validate() error { + if ar.ID != "" { + if ar.Type != "" || ar.Code != "" || ar.Issuer != "" || ar.ContractID != "" { + return fmt.Errorf("when 'id' is provided, other fields should not be present") + } + return nil + } + + if ar.Type == "" { + return fmt.Errorf("either 'id' or 'type' must be provided") + } + + switch AssetReferenceType(ar.Type) { + case AssetReferenceTypeClassic: + if ar.Code == "" { + return fmt.Errorf("'code' is required for classic asset") + } + if ar.Issuer == "" { + return fmt.Errorf("'issuer' is required for classic asset") + } + + if !strkey.IsValidEd25519PublicKey(ar.Issuer) { + return fmt.Errorf("invalid issuer address format") + } + case AssetReferenceTypeNative: + if ar.Code != "" || ar.Issuer != "" || ar.ContractID != "" { + return fmt.Errorf("native asset should not have code, issuer, or contract_id") + } + case AssetReferenceTypeContract, AssetReferenceTypeFiat: + return fmt.Errorf("assets are not implemented yet") + default: + return fmt.Errorf("invalid asset type: %s", ar.Type) + } + + return nil +} + +func (ar AssetReference) GetReferenceType() AssetReferenceType { + if ar.ID != "" { + return AssetReferenceTypeID + } + return AssetReferenceType(ar.Type) +} diff --git a/internal/serve/validators/wallet_validator_test.go b/internal/serve/validators/wallet_validator_test.go index fcaf71201..62761a39f 100644 --- a/internal/serve/validators/wallet_validator_test.go +++ b/internal/serve/validators/wallet_validator_test.go @@ -9,6 +9,7 @@ import ( ) func TestWalletValidator_ValidateCreateWalletRequest(t *testing.T) { + t.Parallel() ctx := context.Background() testCases := []struct { @@ -31,7 +32,7 @@ func TestWalletValidator_ValidateCreateWalletRequest(t *testing.T) { "homepage": "homepage is required", "name": "name is required", "sep_10_client_domain": "sep_10_client_domain is required", - "assets_ids": "provide at least one asset ID", + "assets": "provide at least one 'assets_ids' or 'assets'", }, }, { @@ -101,7 +102,9 @@ func TestWalletValidator_ValidateCreateWalletRequest(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { + t.Parallel() wv := NewWalletValidator() reqBody := wv.ValidateCreateWalletRequest(ctx, tc.reqBody, tc.enforceHTTPS) @@ -120,9 +123,11 @@ func TestWalletValidator_ValidateCreateWalletRequest(t *testing.T) { } func TestWalletValidator_ValidatePatchWalletRequest(t *testing.T) { + t.Parallel() ctx := context.Background() t.Run("returns error when request body is empty", func(t *testing.T) { + t.Parallel() wv := NewWalletValidator() wv.ValidateCreateWalletRequest(ctx, nil, false) assert.True(t, wv.HasErrors()) @@ -130,6 +135,7 @@ func TestWalletValidator_ValidatePatchWalletRequest(t *testing.T) { }) t.Run("returns error when body has empty fields", func(t *testing.T) { + t.Parallel() wv := NewWalletValidator() reqBody := &PatchWalletRequest{} @@ -141,6 +147,7 @@ func TestWalletValidator_ValidatePatchWalletRequest(t *testing.T) { }) t.Run("validates successfully", func(t *testing.T) { + t.Parallel() wv := NewWalletValidator() e := new(bool) @@ -164,3 +171,349 @@ func TestWalletValidator_ValidatePatchWalletRequest(t *testing.T) { assert.Empty(t, wv.Errors) }) } + +func TestAssetReference_Validate(t *testing.T) { + testCases := []struct { + name string + assetRef AssetReference + expectedError string + }{ + { + name: "valid ID reference", + assetRef: AssetReference{ + ID: "ef262966-1cbb-4fdb-9f6f-cc335e954dd1", + }, + expectedError: "", + }, + { + name: "ID reference with other fields should fail", + assetRef: AssetReference{ + ID: "ef262966-1cbb-4fdb-9f6f-cc335e954dd1", + Type: "classic", + Code: "USDC", + }, + expectedError: "when 'id' is provided, other fields should not be present", + }, + + { + name: "valid classic asset", + assetRef: AssetReference{ + Type: "classic", + Code: "USDC", + Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + expectedError: "", + }, + { + name: "classic asset missing code", + assetRef: AssetReference{ + Type: "classic", + Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + expectedError: "'code' is required for classic asset", + }, + { + name: "classic asset missing issuer", + assetRef: AssetReference{ + Type: "classic", + Code: "USDC", + }, + expectedError: "'issuer' is required for classic asset", + }, + { + name: "classic asset with invalid issuer format", + assetRef: AssetReference{ + Type: "classic", + Code: "USDC", + Issuer: "invalid-issuer", + }, + expectedError: "invalid issuer address format", + }, + { + name: "classic asset with issuer starting with wrong character", + assetRef: AssetReference{ + Type: "classic", + Code: "USDC", + Issuer: "ABBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + expectedError: "invalid issuer address format", + }, + + { + name: "valid native asset", + assetRef: AssetReference{ + Type: "native", + }, + expectedError: "", + }, + { + name: "native asset with code should fail", + assetRef: AssetReference{ + Type: "native", + Code: "XLM", + }, + expectedError: "native asset should not have code, issuer, or contract_id", + }, + { + name: "native asset with issuer should fail", + assetRef: AssetReference{ + Type: "native", + Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + expectedError: "native asset should not have code, issuer, or contract_id", + }, + + // Invalid cases + { + name: "empty reference", + assetRef: AssetReference{}, + expectedError: "either 'id' or 'type' must be provided", + }, + { + name: "invalid asset type", + assetRef: AssetReference{ + Type: "invalid-type", + }, + expectedError: "invalid asset type: invalid-type", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.assetRef.Validate() + if tc.expectedError == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + } + }) + } +} + +func TestWalletValidator_ValidateCreateWalletRequest_WithAssets(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + reqBody *WalletRequest + enforceHTTPS bool + expectedError map[string]string + expectedBody *WalletRequest + }{ + { + name: "valid request with legacy assets_ids", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + AssetsIDs: []string{"asset-id-1", "asset-id-2"}, + }, + enforceHTTPS: true, + expectedError: nil, + expectedBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + AssetsIDs: []string{"asset-id-1", "asset-id-2"}, + Assets: nil, + Enabled: boolToPtr(true), + }, + }, + { + name: "valid request with new assets format", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + Assets: []AssetReference{ + {ID: "asset-id-1"}, + {Type: "native"}, + {Type: "classic", Code: "USDC", Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}, + }, + }, + enforceHTTPS: true, + expectedError: nil, + expectedBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + AssetsIDs: nil, + Assets: []AssetReference{ + {ID: "asset-id-1"}, + {Type: "native"}, + {Type: "classic", Code: "USDC", Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}, + }, + Enabled: boolToPtr(true), + }, + }, + { + name: "request with enabled field", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + AssetsIDs: []string{"asset-id-1"}, + Enabled: &[]bool{true}[0], + }, + enforceHTTPS: true, + expectedError: nil, + expectedBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + AssetsIDs: []string{"asset-id-1"}, + Assets: nil, + Enabled: &[]bool{true}[0], + }, + }, + { + name: "error when both assets_ids and assets are provided", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + AssetsIDs: []string{"asset-id-1"}, + Assets: []AssetReference{ + {ID: "asset-id-2"}, + }, + }, + enforceHTTPS: true, + expectedError: map[string]string{ + "assets": "cannot use both 'assets_ids' and 'assets' fields simultaneously", + }, + expectedBody: nil, + }, + { + name: "error when no assets provided", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + }, + enforceHTTPS: true, + expectedError: map[string]string{ + "assets": "provide at least one 'assets_ids' or 'assets'", + }, + expectedBody: nil, + }, + { + name: "error with invalid asset reference", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + Assets: []AssetReference{ + {Type: "classic", Code: "USDC"}, + }, + }, + enforceHTTPS: true, + expectedError: map[string]string{ + "assets[0]": "'issuer' is required for classic asset", + }, + expectedBody: nil, + }, + { + name: "error with multiple invalid asset references", + reqBody: &WalletRequest{ + Name: "Test Wallet", + Homepage: "https://testwallet.com", + DeepLinkSchema: "testwallet://sdp", + SEP10ClientDomain: "testwallet.com", + Assets: []AssetReference{ + {Type: "contract", Code: "USDC", ContractID: "CA..."}, + {Type: "fiat", Code: "USD"}, + }, + }, + enforceHTTPS: true, + expectedError: map[string]string{ + "assets[0]": "assets are not implemented yet", + "assets[1]": "assets are not implemented yet", + }, + expectedBody: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validator := NewWalletValidator() + result := validator.ValidateCreateWalletRequest(ctx, tc.reqBody, tc.enforceHTTPS) + + if tc.expectedError != nil { + require.True(t, validator.HasErrors()) + require.Nil(t, result) + + for field, expectedMsg := range tc.expectedError { + actualErrors, ok := validator.Errors[field] + require.True(t, ok, "Expected error for field %s not found", field) + require.Contains(t, actualErrors, expectedMsg) + } + } else { + require.False(t, validator.HasErrors()) + require.NotNil(t, result) + assert.Equal(t, tc.expectedBody, result) + } + }) + } +} + +func TestWalletValidator_BackwardCompatibility(t *testing.T) { + ctx := context.Background() + + legacyRequest := &WalletRequest{ + Name: "Legacy Wallet", + Homepage: "https://legacy.com", + DeepLinkSchema: "legacy://sdp", + SEP10ClientDomain: "legacy.com", + AssetsIDs: []string{"id-1", "id-2", "id-3"}, + } + + validator := NewWalletValidator() + result := validator.ValidateCreateWalletRequest(ctx, legacyRequest, true) + + require.False(t, validator.HasErrors()) + require.NotNil(t, result) + assert.Equal(t, []string{"id-1", "id-2", "id-3"}, result.AssetsIDs) + assert.Nil(t, result.Assets) +} + +func TestWalletValidator_AssetReferenceValidation(t *testing.T) { + ctx := context.Background() + + request := &WalletRequest{ + Name: "Multi Asset Wallet", + Homepage: "https://multi.com", + DeepLinkSchema: "multi://sdp", + SEP10ClientDomain: "multi.com", + Assets: []AssetReference{ + {ID: "existing-id"}, + {Type: "native"}, + {Type: "classic", Code: "USDC", Issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}, + + {Type: "classic", Code: "BAD"}, + }, + } + + validator := NewWalletValidator() + result := validator.ValidateCreateWalletRequest(ctx, request, true) + + require.True(t, validator.HasErrors()) + require.Nil(t, result) + + errors, ok := validator.Errors["assets[3]"] + require.True(t, ok) + assert.Contains(t, errors, "'issuer' is required for classic asset") +} + +func boolToPtr(b bool) *bool { + return &b +} diff --git a/internal/services/asset_resolver.go b/internal/services/asset_resolver.go new file mode 100644 index 000000000..cb15c47f3 --- /dev/null +++ b/internal/services/asset_resolver.go @@ -0,0 +1,76 @@ +package services + +import ( + "context" + "fmt" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" +) + +type AssetResolver struct { + assetModel *data.AssetModel +} + +func NewAssetResolver(assetModel *data.AssetModel) *AssetResolver { + return &AssetResolver{ + assetModel: assetModel, + } +} + +func (ar *AssetResolver) ResolveAssetReferences(ctx context.Context, references []validators.AssetReference) ([]string, error) { + assetIDs := make([]string, 0, len(references)) + + for i, ref := range references { + assetID, err := ar.resolveAssetReference(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to resolve asset reference at index %d: %w", i, err) + } + assetIDs = append(assetIDs, assetID) + } + + return assetIDs, nil +} + +func (ar *AssetResolver) resolveAssetReference(ctx context.Context, ref validators.AssetReference) (string, error) { + switch ref.GetReferenceType() { + case validators.AssetReferenceTypeID: + asset, err := ar.assetModel.Get(ctx, ref.ID) + if err != nil { + if err == data.ErrRecordNotFound { + return "", fmt.Errorf("asset with ID %s not found", ref.ID) + } + return "", fmt.Errorf("failed to get asset by ID: %w", err) + } + return asset.ID, nil + case validators.AssetReferenceTypeClassic: + asset, err := ar.assetModel.GetOrCreate(ctx, ref.Code, ref.Issuer) + if err != nil { + return "", fmt.Errorf("failed to get or create classic asset: %w", err) + } + return asset.ID, nil + case validators.AssetReferenceTypeNative: + asset, err := ar.assetModel.GetOrCreate(ctx, "XLM", "") + if err != nil { + return "", fmt.Errorf("failed to get or create native asset: %w", err) + } + return asset.ID, nil + case validators.AssetReferenceTypeContract, validators.AssetReferenceTypeFiat: + return "", fmt.Errorf("assets are not implemented yet") + default: + return "", fmt.Errorf("unknown asset reference type") + } +} + +func (ar *AssetResolver) ValidateAssetIDs(ctx context.Context, assetIDs []string) error { + for _, assetID := range assetIDs { + _, err := ar.assetModel.Get(ctx, assetID) + if err != nil { + if err == data.ErrRecordNotFound { + return fmt.Errorf("asset with ID %s not found", assetID) + } + return fmt.Errorf("failed to validate asset ID %s: %w", assetID, err) + } + } + return nil +}