Repository: prebid/prebid-server
Pull request #3704 (2024-05-23 15:47:54)

Bidder-specific imp FPD
Implements #2335

This PR merges bidder-specific imp first party data into the applicable bidder request. After merging the imps are revalidated. If there is a validation error, it is considered a fatal error which means the auction is halted and the error is reported back to the client.

While attempting to implement merge and validation error handling, I noticed that several preexisting errors captured in
`cleanOpenRTBRequests` are actually swallowed and never reported to the client. Consequently, this also effects errors from failed imp FPD merges. In order to ensure imp FPD merge errors are made known to the client while avoiding a large general error handling refactor, I created an imp FPD merge error type with a fatal severity and detect when it occurs so I can halt the auction and send the error to the client.

VeronikaSolovei9: I don't think it's ready, ValidateImp is still commented out.

Patch:
diff --git a/adapters/yieldmo/yieldmo.go b/adapters/yieldmo/yieldmo.go
index 6693861c..107622e9 100644
--- a/adapters/yieldmo/yieldmo.go
+++ b/adapters/yieldmo/yieldmo.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/prebid/openrtb/v20/openrtb2"
 	"github.com/prebid/prebid-server/v2/adapters"
@@ -35,16 +36,11 @@ type ExtBid struct {
 }
 
 func (a *YieldmoAdapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) {
-	reqData, errors := a.makeRequest(request)
-	return []*adapters.RequestData{reqData}, errors
-}
-
-func (a *YieldmoAdapter) makeRequest(request *openrtb2.BidRequest) (*adapters.RequestData, []error) {
 	var errs []error
 
-	if err := preprocess(request); err != nil {
-		errs = append(errs, err)
-	}
+	preprocessErrors := preprocess(request, reqInfo)
+
+	errs = append(errs, preprocessErrors...)
 
 	// Last Step
 	reqJSON, err := json.Marshal(request)
@@ -56,33 +52,47 @@ func (a *YieldmoAdapter) makeRequest(request *openrtb2.BidRequest) (*adapters.Re
 	headers := http.Header{}
 	headers.Add("Content-Type", "application/json;charset=utf-8")
 
-	return &adapters.RequestData{
+	return []*adapters.RequestData{{
 		Method:  "POST",
 		Uri:     a.endpoint,
 		Body:    reqJSON,
 		Headers: headers,
 		ImpIDs:  openrtb_ext.GetImpIDs(request.Imp),
-	}, errs
+	}}, errs
 }
 
 // Mutate the request to get it ready to send to yieldmo.
-func preprocess(request *openrtb2.BidRequest) error {
+func preprocess(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) []error {
+	var errs []error
+
 	for i := 0; i < len(request.Imp); i++ {
 		var imp = request.Imp[i]
 		var bidderExt ExtImpBidderYieldmo
 
+		if imp.BidFloor > 0 && imp.BidFloorCur != "" && strings.ToUpper(imp.BidFloorCur) != "USD" {
+			floor, err := reqInfo.ConvertCurrency(imp.BidFloor, imp.BidFloorCur, "USD")
+			if err != nil {
+				errs = append(errs, &errortypes.BadInput{
+					Message: fmt.Sprintf("Unable to convert provided bid floor currency from %s to USD", imp.BidFloorCur),
+				})
+			} else {
+				request.Imp[i].BidFloorCur = "USD"
+				request.Imp[i].BidFloor = floor
+			}
+		}
+
 		if err := json.Unmarshal(imp.Ext, &bidderExt); err != nil {
-			return &errortypes.BadInput{
+			errs = append(errs, &errortypes.BadInput{
 				Message: err.Error(),
-			}
+			})
 		}
 
 		var yieldmoExt openrtb_ext.ExtImpYieldmo
 
 		if err := json.Unmarshal(bidderExt.Bidder, &yieldmoExt); err != nil {
-			return &errortypes.BadInput{
+			errs = append(errs, &errortypes.BadInput{
 				Message: err.Error(),
-			}
+			})
 		}
 
 		var impExt Ext
@@ -96,15 +106,15 @@ func preprocess(request *openrtb2.BidRequest) error {
 
 		impExtJSON, err := json.Marshal(impExt)
 		if err != nil {
-			return &errortypes.BadInput{
+			errs = append(errs, &errortypes.BadInput{
 				Message: err.Error(),
-			}
+			})
 		}
 
 		request.Imp[i].Ext = impExtJSON
 	}
 
-	return nil
+	return errs
 }
 
 // MakeBids make the bids for the bid response.
diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go
index 4dbb0301..4a409a67 100644
--- a/endpoints/openrtb2/amp_auction.go
+++ b/endpoints/openrtb2/amp_auction.go
@@ -236,8 +236,8 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h
 		errL = append(errL, errs...)
 	}
 
-	hasStoredResponses := len(storedAuctionResponses) > 0
-	errs := deps.validateRequest(account, r, reqWrapper, true, hasStoredResponses, storedBidResponses, false)
+	hasStoredAuctionResponses := len(storedAuctionResponses) > 0
+	errs := deps.validateRequest(account, r, reqWrapper, true, hasStoredAuctionResponses, storedBidResponses, false)
 	errL = append(errL, errs...)
 	ao.Errors = append(ao.Errors, errs...)
 	if errortypes.ContainsFatalError(errs) {
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index eedb1b71..d6ee5303 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -562,8 +562,8 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request, labels *metric
 		return nil, nil, nil, nil, nil, nil, errs
 	}
 
-	hasStoredResponses := len(storedAuctionResponses) > 0
-	errL = deps.validateRequest(account, httpRequest, req, false, hasStoredResponses, storedBidResponses, hasStoredBidRequest)
+	hasStoredAuctionResponses := len(storedAuctionResponses) > 0
+	errL = deps.validateRequest(account, httpRequest, req, false, hasStoredAuctionResponses, storedBidResponses, hasStoredBidRequest)
 	if len(errL) > 0 {
 		errs = append(errs, errL...)
 	}
@@ -757,7 +757,7 @@ func mergeBidderParamsImpExtPrebid(impExt *openrtb_ext.ImpExt, reqExtParams map[
 	return nil
 }
 
-func (deps *endpointDeps) validateRequest(account *config.Account, httpReq *http.Request, req *openrtb_ext.RequestWrapper, isAmp bool, hasStoredResponses bool, storedBidResp stored_responses.ImpBidderStoredResp, hasStoredBidRequest bool) []error {
+func (deps *endpointDeps) validateRequest(account *config.Account, httpReq *http.Request, req *openrtb_ext.RequestWrapper, isAmp bool, hasStoredAuctionResponses bool, storedBidResp stored_responses.ImpBidderStoredResp, hasStoredBidRequest bool) []error {
 	errL := []error{}
 	if req.ID == "" {
 		return []error{errors.New("request missing required field: \"id\"")}
@@ -918,7 +918,7 @@ func (deps *endpointDeps) validateRequest(account *config.Account, httpReq *http
 		}
 		impIDs[imp.ID] = i
 
-		errs := deps.requestValidator.ValidateImp(imp, i, requestAliases, hasStoredResponses, storedBidResp)
+		errs := deps.requestValidator.ValidateImp(imp, i, requestAliases, hasStoredAuctionResponses, storedBidResp)
 		if len(errs) > 0 {
 			errL = append(errL, errs...)
 		}
diff --git a/errortypes/code.go b/errortypes/code.go
index a56df67c..552beffb 100644
--- a/errortypes/code.go
+++ b/errortypes/code.go
@@ -17,6 +17,7 @@ const (
 	TmaxTimeoutErrorCode
 	FailedToMarshalErrorCode
 	FailedToUnmarshalErrorCode
+	InvalidImpFirstPartyDataErrorCode
 )
 
 // Defines numeric codes for well-known warnings.
diff --git a/errortypes/errortypes.go b/errortypes/errortypes.go
index 7ca5668c..f23e1423 100644
--- a/errortypes/errortypes.go
+++ b/errortypes/errortypes.go
@@ -274,3 +274,21 @@ func (err *DebugWarning) Severity() Severity {
 func (err *DebugWarning) Scope() Scope {
 	return ScopeDebug
 }
+
+// InvalidImpFirstPartyData should be used when the retrieved account config cannot be unmarshaled
+// These errors will be written to http.ResponseWriter before canceling execution
+type InvalidImpFirstPartyData struct {
+	Message string
+}
+
+func (err *InvalidImpFirstPartyData) Error() string {
+	return err.Message
+}
+
+func (err *InvalidImpFirstPartyData) Code() int {
+	return InvalidImpFirstPartyDataErrorCode
+}
+
+func (err *InvalidImpFirstPartyData) Severity() Severity {
+	return SeverityFatal
+}
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 01abe37d..5c27b0d3 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/prebid/prebid-server/v2/ortb"
 	"github.com/prebid/prebid-server/v2/privacy"
 
 	"github.com/prebid/prebid-server/v2/adapters"
@@ -132,7 +133,7 @@ func (randomDeduplicateBidBooleanGenerator) Generate() bool {
 	return rand.Intn(100) < 50
 }
 
-func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid_cache_client.Client, cfg *config.Configuration, syncersByBidder map[string]usersync.Syncer, metricsEngine metrics.MetricsEngine, infos config.BidderInfos, gdprPermsBuilder gdpr.PermissionsBuilder, currencyConverter *currency.RateConverter, categoriesFetcher stored_requests.CategoryFetcher, adsCertSigner adscert.Signer, macroReplacer macros.Replacer, priceFloorFetcher floors.FloorFetcher) Exchange {
+func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid_cache_client.Client, cfg *config.Configuration, requestValidator ortb.RequestValidator, syncersByBidder map[string]usersync.Syncer, metricsEngine metrics.MetricsEngine, infos config.BidderInfos, gdprPermsBuilder gdpr.PermissionsBuilder, currencyConverter *currency.RateConverter, categoriesFetcher stored_requests.CategoryFetcher, adsCertSigner adscert.Signer, macroReplacer macros.Replacer, priceFloorFetcher floors.FloorFetcher) Exchange {
 	bidderToSyncerKey := map[string]string{}
 	for bidder, syncer := range syncersByBidder {
 		bidderToSyncerKey[bidder] = syncer.Key()
@@ -155,6 +156,7 @@ func NewExchange(adapters map[openrtb_ext.BidderName]AdaptedBidder, cache prebid
 		gdprPermsBuilder:  gdprPermsBuilder,
 		hostSChainNode:    cfg.HostSChainNode,
 		bidderInfo:        infos,
+		requestValidator:  requestValidator,
 	}
 
 	return &exchange{
@@ -338,6 +340,11 @@ func (e *exchange) HoldAuction(ctx context.Context, r *AuctionRequest, debugLog
 		SChain: requestExt.GetSChain(),
 	}
 	bidderRequests, privacyLabels, errs := e.requestSplitter.cleanOpenRTBRequests(ctx, *r, requestExtLegacy, gdprSignal, gdprEnforced, bidAdjustmentFactors)
+	for _, err := range errs {
+		if errortypes.ReadCode(err) == errortypes.InvalidImpFirstPartyDataErrorCode {
+			return nil, err
+		}
+	}
 	errs = append(errs, floorErrs...)
 
 	mergedBidAdj, err := bidadjustment.Merge(r.BidRequestWrapper, r.Account.BidAdjustments)
diff --git a/exchange/utils.go b/exchange/utils.go
index e64ac6aa..2074d7f2 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -22,6 +22,7 @@ import (
 	"github.com/prebid/prebid-server/v2/gdpr"
 	"github.com/prebid/prebid-server/v2/metrics"
 	"github.com/prebid/prebid-server/v2/openrtb_ext"
+	// "github.com/prebid/prebid-server/v2/ortb/validation"
 	"github.com/prebid/prebid-server/v2/privacy"
 	"github.com/prebid/prebid-server/v2/privacy/ccpa"
 	"github.com/prebid/prebid-server/v2/privacy/lmt"
@@ -50,6 +51,7 @@ type requestSplitter struct {
 	gdprPermsBuilder  gdpr.PermissionsBuilder
 	hostSChainNode    *openrtb2.SupplyChainNode
 	bidderInfo        config.BidderInfos
+	requestValidator  ortb.RequestValidator
 }
 
 // cleanOpenRTBRequests splits the input request into requests which are sanitized for each bidder. Intended behavior is:
@@ -75,7 +77,9 @@ func (rs *requestSplitter) cleanOpenRTBRequests(ctx context.Context,
 
 	bidderImpWithBidResp := stored_responses.InitStoredBidResponses(req.BidRequest, auctionReq.StoredBidResponses)
 
-	impsByBidder, err := splitImps(req.BidRequest.Imp)
+	hasStoredAuctionResponses := len(auctionReq.StoredAuctionResponses) > 0
+
+	impsByBidder, err := splitImps(req.BidRequest.Imp, rs.requestValidator, requestAliases, hasStoredAuctionResponses, auctionReq.StoredBidResponses)
 	if err != nil {
 		errs = []error{err}
 		return
@@ -566,7 +570,7 @@ func extractBuyerUIDs(user *openrtb2.User) (map[string]string, error) {
 // The "imp.ext" value of the rubicon Imp will only contain the "prebid" values, and "rubicon" value at the "bidder" key.
 //
 // The goal here is so that Bidders only get Imps and Imp.Ext values which are intended for them.
-func splitImps(imps []openrtb2.Imp) (map[string][]openrtb2.Imp, error) {
+func splitImps(imps []openrtb2.Imp, requestValidator ortb.RequestValidator, requestAliases map[string]string, hasStoredAuctionResponses bool, storedBidResponses stored_responses.ImpBidderStoredResp) (map[string][]openrtb2.Imp, error) {
 	bidderImps := make(map[string][]openrtb2.Imp)
 
 	for i, imp := range imps {
@@ -587,6 +591,11 @@ func splitImps(imps []openrtb2.Imp) (map[string][]openrtb2.Imp, error) {
 			jsonutil.Unmarshal(impExtPrebidBidderJSON, &impExtPrebidBidder)
 		}
 
+		var impExtPrebidImp map[string]json.RawMessage
+		if impExtPrebidImpJSON, exists := impExtPrebid["imp"]; exists {
+			jsonutil.Unmarshal(impExtPrebidImpJSON, &impExtPrebidImp)
+		}
+
 		sanitizedImpExt, err := createSanitizedImpExt(impExt, impExtPrebid)
 		if err != nil {
 			return nil, fmt.Errorf("unable to remove other bidder fields for imp[%d]: %v", i, err)
@@ -595,6 +604,18 @@ func splitImps(imps []openrtb2.Imp) (map[string][]openrtb2.Imp, error) {
 		for bidder, bidderExt := range impExtPrebidBidder {
 			impCopy := imp
 
+			if impBidderFPD, exists := impExtPrebidImp[bidder]; exists {
+				if err := mergeImpFPD(&impCopy, impBidderFPD, i); err != nil {
+					return nil, err
+				}
+				impWrapper := openrtb_ext.ImpWrapper{Imp: &impCopy}
+				if err := requestValidator.ValidateImp(&impWrapper, i, requestAliases, hasStoredAuctionResponses, storedBidResponses); err != nil {
+					return nil, &errortypes.InvalidImpFirstPartyData{
+						Message: fmt.Sprintf("merging bidder imp first party data for imp %s results in an invalid imp: %v", imp.ID, err),
+					}
+				}
+			}
+
 			sanitizedImpExt[openrtb_ext.PrebidExtBidderKey] = bidderExt
 
 			impExtJSON, err := jsonutil.Marshal(sanitizedImpExt)
@@ -610,6 +631,16 @@ func splitImps(imps []openrtb2.Imp) (map[string][]openrtb2.Imp, error) {
 	return bidderImps, nil
 }
 
+func mergeImpFPD(imp *openrtb2.Imp, fpd json.RawMessage, index int) error {
+	if err := jsonutil.MergeClone(imp, fpd); err != nil {
+		if strings.Contains(err.Error(), "invalid json on existing object") {
+			return fmt.Errorf("invalid imp ext for imp[%d]", index)
+		}
+		return fmt.Errorf("invalid first party data for imp[%d]", index)
+	}
+	return nil
+}
+
 var allowedImpExtFields = map[string]interface{}{
 	openrtb_ext.AuctionEnvironmentKey:       struct{}{},
 	openrtb_ext.FirstPartyDataExtKey:        struct{}{},
diff --git a/openrtb_ext/imp.go b/openrtb_ext/imp.go
index e5779ed3..e02cfadc 100644
--- a/openrtb_ext/imp.go
+++ b/openrtb_ext/imp.go
@@ -46,6 +46,9 @@ type ExtImpPrebid struct {
 	Passthrough json.RawMessage `json:"passthrough,omitempty"`
 
 	Floors *ExtImpPrebidFloors `json:"floors,omitempty"`
+
+	// Imp specifies any imp bidder-specific first party data
+	Imp map[string]json.RawMessage `json:"imp,omitempty"`
 }
 
 type ExtImpDataAdServer struct {
diff --git a/ortb/request_validator.go b/ortb/request_validator.go
index 79d1f930..e38b8da2 100644
--- a/ortb/request_validator.go
+++ b/ortb/request_validator.go
@@ -10,7 +10,7 @@ import (
 )
 
 type RequestValidator interface {
-	ValidateImp(imp *openrtb_ext.ImpWrapper, index int, aliases map[string]string, hasStoredResponses bool, storedBidResponses stored_responses.ImpBidderStoredResp) []error
+	ValidateImp(imp *openrtb_ext.ImpWrapper, index int, aliases map[string]string, hasStoredAuctionResponses bool, storedBidResponses stored_responses.ImpBidderStoredResp) []error
 }
 
 func NewRequestValidator(bidderMap map[string]openrtb_ext.BidderName, disabledBidders map[string]string, paramsValidator openrtb_ext.BidderParamValidator) RequestValidator {
@@ -27,7 +27,7 @@ type standardRequestValidator struct {
 	paramsValidator openrtb_ext.BidderParamValidator
 }
 
-func (srv *standardRequestValidator) ValidateImp(imp *openrtb_ext.ImpWrapper, index int, aliases map[string]string, hasStoredResponses bool, storedBidResponses stored_responses.ImpBidderStoredResp) []error {
+func (srv *standardRequestValidator) ValidateImp(imp *openrtb_ext.ImpWrapper, index int, aliases map[string]string, hasStoredAuctionResponses bool, storedBidResponses stored_responses.ImpBidderStoredResp) []error {
 	if imp.ID == "" {
 		return []error{fmt.Errorf("request.imp[%d] missing required field: \"id\"", index)}
 	}
@@ -60,7 +60,7 @@ func (srv *standardRequestValidator) ValidateImp(imp *openrtb_ext.ImpWrapper, in
 		return []error{err}
 	}
 
-	errL := srv.validateImpExt(imp, aliases, index, hasStoredResponses, storedBidResponses)
+	errL := srv.validateImpExt(imp, aliases, index, hasStoredAuctionResponses, storedBidResponses)
 	if len(errL) != 0 {
 		return errL
 	}
@@ -68,7 +68,7 @@ func (srv *standardRequestValidator) ValidateImp(imp *openrtb_ext.ImpWrapper, in
 	return nil
 }
 
-func (srv *standardRequestValidator) validateImpExt(imp *openrtb_ext.ImpWrapper, aliases map[string]string, impIndex int, hasStoredResponses bool, storedBidResp stored_responses.ImpBidderStoredResp) []error {
+func (srv *standardRequestValidator) validateImpExt(imp *openrtb_ext.ImpWrapper, aliases map[string]string, impIndex int, hasStoredAuctionResponses bool, storedBidResp stored_responses.ImpBidderStoredResp) []error {
 	if len(imp.Ext) == 0 {
 		return []error{fmt.Errorf("request.imp[%d].ext is required", impIndex)}
 	}
@@ -100,7 +100,7 @@ func (srv *standardRequestValidator) validateImpExt(imp *openrtb_ext.ImpWrapper,
 		}
 	}
 
-	if hasStoredResponses && prebid.StoredAuctionResponse == nil {
+	if hasStoredAuctionResponses && prebid.StoredAuctionResponse == nil {
 		return []error{fmt.Errorf("request validation failed. The StoredAuctionResponse.ID field must be completely present with, or completely absent from, all impressions in request. No StoredAuctionResponse data found for request.imp[%d].ext.prebid \n", impIndex)}
 	}
 
diff --git a/router/router.go b/router/router.go
index 308f8d4f..c4020383 100644
--- a/router/router.go
+++ b/router/router.go
@@ -242,7 +242,7 @@ func New(cfg *config.Configuration, rateConvertor *currency.RateConverter) (r *R
 	tmaxAdjustments := exchange.ProcessTMaxAdjustments(cfg.TmaxAdjustments)
 	planBuilder := hooks.NewExecutionPlanBuilder(cfg.Hooks, repo)
 	macroReplacer := macros.NewStringIndexBasedReplacer()
-	theExchange := exchange.NewExchange(adapters, cacheClient, cfg, syncersByBidder, r.MetricsEngine, cfg.BidderInfos, gdprPermsBuilder, rateConvertor, categoriesFetcher, adsCertSigner, macroReplacer, priceFloorFetcher)
+	theExchange := exchange.NewExchange(adapters, cacheClient, cfg, requestValidator, syncersByBidder, r.MetricsEngine, cfg.BidderInfos, gdprPermsBuilder, rateConvertor, categoriesFetcher, adsCertSigner, macroReplacer, priceFloorFetcher)
 	var uuidGenerator uuidutil.UUIDRandomGenerator
 	openrtbEndpoint, err := openrtb2.NewEndpoint(uuidGenerator, theExchange, requestValidator, fetcher, accounts, cfg, r.MetricsEngine, analyticsRunner, disabledBidders, defReqJSON, activeBidders, storedRespFetcher, planBuilder, tmaxAdjustments)
 	if err != nil {
diff --git a/static/bidder-info/tredio.yaml b/static/bidder-info/tredio.yaml
new file mode 100644
index 00000000..5ed85a93
--- /dev/null
+++ b/static/bidder-info/tredio.yaml
@@ -0,0 +1,5 @@
+endpoint: "http://tredio-prebid.smart-hub.io/pbserver/?seat={{.AccountID}}&token={{.SourceId}}"
+aliasOf: "smarthub"
+userSync:
+  supports:
+    - redirect
\ No newline at end of file


Test patch:
diff --git a/adapters/yieldmo/yieldmotest/exemplary/valid_currency_conversion.json b/adapters/yieldmo/yieldmotest/exemplary/valid_currency_conversion.json
new file mode 100644
index 00000000..15b4aa3a
--- /dev/null
+++ b/adapters/yieldmo/yieldmotest/exemplary/valid_currency_conversion.json
@@ -0,0 +1,150 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-imp-id",
+        "bidfloor": 1.0,
+        "bidfloorcur": "EUR",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "placementId": "123"
+          }
+        }
+      }
+    ],
+    "site": {
+      "id": "fake-site-id"
+    },
+    "regs": {
+      "coppa": 1,
+      "ext": {
+        "gdpr": 1,
+        "us_privacy": "uspConsentString",
+        "gpp": "gppString",
+        "gpp_sid": [6]
+      }
+    },
+    "ext": {
+      "prebid": {
+        "currency": {
+          "rates": {
+            "EUR": {
+              "USD": 1.1
+            }
+          }
+        }
+      }
+    }
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://ads.yieldmo.com/openrtb2",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-imp-id",
+              "bidfloor": 1.1,
+              "bidfloorcur": "USD",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 250
+                  }
+                ]
+              },
+              "ext": {
+                "placement_id": "123"
+              }
+            }
+          ],
+          "site": {
+            "id": "fake-site-id"
+          },
+          "regs": {
+            "coppa": 1,
+            "ext": {
+              "gdpr": 1,
+              "us_privacy": "uspConsentString",
+              "gpp": "gppString",
+              "gpp_sid": [6]
+            }
+          },
+          "ext": {
+            "prebid": {
+              "currency": {
+                "rates": {
+                  "EUR": {
+                    "USD": 1.1
+                  }
+                }
+              }
+            }
+          }
+        },
+        "impIDs":["test-imp-id"]
+      },
+      "mockResponse": {
+        "status": 200,
+        "body": {
+          "id": "test-request-id",
+          "seatbid": [
+            {
+              "seat": "yieldmo",
+              "bid": [
+                {
+                  "id": "8ee514f1-b2b8-4abb-89fd-084437d1e800",
+                  "impid": "test-imp-id",
+                  "price": 1.500000,
+                  "adm": "some-test-ad",
+                  "crid": "crid_10",
+                  "h": 250,
+                  "w": 300,
+                  "ext":
+                    {
+                      "mediatype": "banner"
+                    }
+                }
+              ]
+            }
+          ],
+          "cur": "USD"
+        }
+      }
+    }
+  ],
+  "expectedBidResponses": [
+    {
+      "currency": "USD",
+      "bids": [
+        {
+          "bid": {
+            "id": "8ee514f1-b2b8-4abb-89fd-084437d1e800",
+            "impid": "test-imp-id",
+            "price": 1.5,
+            "adm": "some-test-ad",
+            "crid": "crid_10",
+            "w": 300,
+            "h": 250,
+            "ext":
+            {
+              "mediatype": "banner"
+            }
+          },
+          "type": "banner"
+        }
+      ]
+    }
+  ]
+}
diff --git a/adapters/yieldmo/yieldmotest/supplemental/unsupported_currency.json b/adapters/yieldmo/yieldmotest/supplemental/unsupported_currency.json
new file mode 100644
index 00000000..a6d108aa
--- /dev/null
+++ b/adapters/yieldmo/yieldmotest/supplemental/unsupported_currency.json
@@ -0,0 +1,109 @@
+{
+  "mockBidRequest": {
+    "id": "test-request-id",
+    "imp": [
+      {
+        "id": "test-imp-id",
+        "bidfloor": 1.0,
+        "bidfloorcur": "EUR",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            }
+          ]
+        },
+        "ext": {
+          "bidder": {
+            "placementId": "123"
+          }
+        }
+      }
+    ],
+    "site": {
+      "id": "fake-site-id"
+    },
+    "regs": {
+      "coppa": 1,
+      "ext": {
+        "gdpr": 1,
+        "us_privacy": "uspConsentString",
+        "gpp": "gppString",
+        "gpp_sid": [6]
+      }
+    },
+    "ext": {
+      "prebid": {
+        "currency": {
+          "rates": {
+            "EUR": {
+              "GBP": 0.85
+            }
+          }
+        }
+      }
+    }
+  },
+  "httpCalls": [
+    {
+      "expectedRequest": {
+        "uri": "https://ads.yieldmo.com/openrtb2",
+        "body": {
+          "id": "test-request-id",
+          "imp": [
+            {
+              "id": "test-imp-id",
+              "bidfloor": 1.0,
+              "bidfloorcur": "EUR",
+              "banner": {
+                "format": [
+                  {
+                    "w": 300,
+                    "h": 250
+                  }
+                ]
+              },
+              "ext": {
+                "placement_id": "123"
+              }
+            }
+          ],
+          "site": {
+            "id": "fake-site-id"
+          },
+          "regs": {
+            "coppa": 1,
+            "ext": {
+              "gdpr": 1,
+              "us_privacy": "uspConsentString",
+              "gpp": "gppString",
+              "gpp_sid": [6]
+            }
+          },
+          "ext": {
+            "prebid": {
+              "currency": {
+                "rates": {
+                  "EUR": {
+                    "GBP": 0.85
+                  }
+                }
+              }
+            }
+          }
+        },
+        "impIDs":["test-imp-id"]
+      },
+      "mockResponse": {
+        "status": 204
+      }
+    }
+  ],
+  "expectedMakeRequestsErrors": [
+    {
+      "value": "Unable to convert provided bid floor currency from EUR to USD",
+      "comparison": "literal"
+    }
+  ]
+}
diff --git a/endpoints/openrtb2/auction_benchmark_test.go b/endpoints/openrtb2/auction_benchmark_test.go
index b3e97b4d..d3dbc518 100644
--- a/endpoints/openrtb2/auction_benchmark_test.go
+++ b/endpoints/openrtb2/auction_benchmark_test.go
@@ -72,6 +72,7 @@ func BenchmarkOpenrtbEndpoint(b *testing.B) {
 	if err != nil {
 		return
 	}
+	requestValidator := ortb.NewRequestValidator(nil, map[string]string{}, paramValidator)
 
 	nilMetrics := &metricsConfig.NilMetricsEngine{}
 
@@ -88,6 +89,7 @@ func BenchmarkOpenrtbEndpoint(b *testing.B) {
 		adapters,
 		nil,
 		&config.Configuration{},
+		requestValidator,
 		map[string]usersync.Syncer{},
 		nilMetrics,
 		infos,
@@ -102,7 +104,7 @@ func BenchmarkOpenrtbEndpoint(b *testing.B) {
 	endpoint, _ := NewEndpoint(
 		fakeUUIDGenerator{},
 		exchange,
-		ortb.NewRequestValidator(nil, map[string]string{}, paramValidator),
+		requestValidator,
 		empty_fetcher.EmptyFetcher{},
 		empty_fetcher.EmptyFetcher{},
 		&config.Configuration{MaxRequestSize: maxSize},
diff --git a/endpoints/openrtb2/test_utils.go b/endpoints/openrtb2/test_utils.go
index 0521befa..b2f28267 100644
--- a/endpoints/openrtb2/test_utils.go
+++ b/endpoints/openrtb2/test_utils.go
@@ -1197,7 +1197,7 @@ func (te *exchangeTestWrapper) HoldAuction(ctx context.Context, r *exchange.Auct
 }
 
 // buildTestExchange returns an exchange with mock bidder servers and mock currency conversion server
-func buildTestExchange(testCfg *testConfigValues, adapterMap map[openrtb_ext.BidderName]exchange.AdaptedBidder, mockBidServersArray []*httptest.Server, mockCurrencyRatesServer *httptest.Server, bidderInfos config.BidderInfos, cfg *config.Configuration, met metrics.MetricsEngine, mockFetcher stored_requests.CategoryFetcher) (exchange.Exchange, []*httptest.Server) {
+func buildTestExchange(testCfg *testConfigValues, adapterMap map[openrtb_ext.BidderName]exchange.AdaptedBidder, mockBidServersArray []*httptest.Server, mockCurrencyRatesServer *httptest.Server, bidderInfos config.BidderInfos, cfg *config.Configuration, met metrics.MetricsEngine, mockFetcher stored_requests.CategoryFetcher, requestValidator ortb.RequestValidator) (exchange.Exchange, []*httptest.Server) {
 	if len(testCfg.MockBidders) == 0 {
 		testCfg.MockBidders = append(testCfg.MockBidders, mockBidderHandler{BidderName: "appnexus", Currency: "USD", Price: 0.00})
 	}
@@ -1220,6 +1220,7 @@ func buildTestExchange(testCfg *testConfigValues, adapterMap map[openrtb_ext.Bid
 	testExchange := exchange.NewExchange(adapterMap,
 		&wellBehavedCache{},
 		cfg,
+		requestValidator,
 		nil,
 		met,
 		bidderInfos,
@@ -1276,7 +1277,7 @@ func buildTestEndpoint(test testCase, cfg *config.Configuration) (httprouter.Han
 	}
 	mockCurrencyRatesServer := httptest.NewServer(http.HandlerFunc(mockCurrencyConversionService.handle))
 
-	testExchange, mockBidServersArray := buildTestExchange(test.Config, adapterMap, mockBidServersArray, mockCurrencyRatesServer, bidderInfos, cfg, met, mockFetcher)
+	testExchange, mockBidServersArray := buildTestExchange(test.Config, adapterMap, mockBidServersArray, mockCurrencyRatesServer, bidderInfos, cfg, met, mockFetcher, requestValidator)
 
 	var storedRequestFetcher stored_requests.Fetcher
 	if len(test.StoredRequest) > 0 {
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 5849dbc3..162f4255 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -35,10 +35,12 @@ import (
 	metricsConf "github.com/prebid/prebid-server/v2/metrics/config"
 	metricsConfig "github.com/prebid/prebid-server/v2/metrics/config"
 	"github.com/prebid/prebid-server/v2/openrtb_ext"
+	"github.com/prebid/prebid-server/v2/ortb"
 	pbc "github.com/prebid/prebid-server/v2/prebid_cache_client"
 	"github.com/prebid/prebid-server/v2/privacy"
 	"github.com/prebid/prebid-server/v2/stored_requests"
 	"github.com/prebid/prebid-server/v2/stored_requests/backends/file_fetcher"
+	"github.com/prebid/prebid-server/v2/stored_responses"
 	"github.com/prebid/prebid-server/v2/usersync"
 	"github.com/prebid/prebid-server/v2/util/jsonutil"
 	"github.com/prebid/prebid-server/v2/util/ptrutil"
@@ -82,7 +84,7 @@ func TestNewExchange(t *testing.T) {
 		},
 	}.Builder
 
-	e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, nil, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 	for _, bidderName := range knownAdapters {
 		if _, ok := e.adapterMap[bidderName]; !ok {
 			if biddersInfo[string(bidderName)].IsEnabled() {
@@ -132,7 +134,7 @@ func TestCharacterEscape(t *testing.T) {
 		},
 	}.Builder
 
-	e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, nil, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 
 	// 	3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs
 	//liveAdapters []openrtb_ext.BidderName,
@@ -1235,7 +1237,7 @@ func TestGetBidCacheInfoEndToEnd(t *testing.T) {
 		},
 	}.Builder
 
-	e := NewExchange(adapters, pbc, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, pbc, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 	// 	3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs
 	liveAdapters := []openrtb_ext.BidderName{bidderName}
 
@@ -1594,7 +1596,7 @@ func TestBidResponseCurrency(t *testing.T) {
 		},
 	}.Builder
 
-	e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, nil, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 
 	liveAdapters := make([]openrtb_ext.BidderName, 1)
 	liveAdapters[0] = "appnexus"
@@ -1742,7 +1744,7 @@ func TestBidResponseImpExtInfo(t *testing.T) {
 		t.Fatalf("Error intializing adapters: %v", adaptersErr)
 	}
 
-	e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, nil, gdprPermsBuilder, nil, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, nil, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, nil, gdprPermsBuilder, nil, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 
 	liveAdapters := make([]openrtb_ext.BidderName, 1)
 	liveAdapters[0] = "appnexus"
@@ -1836,7 +1838,7 @@ func TestRaceIntegration(t *testing.T) {
 		},
 	}.Builder
 
-	ex := NewExchange(adapters, &wellBehavedCache{}, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, &nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	ex := NewExchange(adapters, &wellBehavedCache{}, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, &nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 	_, err = ex.HoldAuction(context.Background(), auctionRequest, &debugLog)
 	if err != nil {
 		t.Errorf("HoldAuction returned unexpected error: %v", err)
@@ -1934,7 +1936,7 @@ func TestPanicRecovery(t *testing.T) {
 		},
 	}.Builder
 
-	e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, nil, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 
 	chBids := make(chan *bidResponseWrapper, 1)
 	panicker := func(bidderRequest BidderRequest, conversions currency.Conversions) {
@@ -2004,7 +2006,7 @@ func TestPanicRecoveryHighLevel(t *testing.T) {
 			allowAllBidders: true,
 		},
 	}.Builder
-	e := NewExchange(adapters, &mockCache{}, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, categoriesFetcher, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, &mockCache{}, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, categoriesFetcher, &adscert.NilSigner{}, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 
 	e.adapterMap[openrtb_ext.BidderBeachfront] = panicingAdapter{}
 	e.adapterMap[openrtb_ext.BidderAppnexus] = panicingAdapter{}
@@ -2335,7 +2337,8 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) {
 }
 
 func findBiddersInAuction(t *testing.T, context string, req *openrtb2.BidRequest) []string {
-	if splitImps, err := splitImps(req.Imp); err != nil {
+
+	if splitImps, err := splitImps(req.Imp, &mockRequestValidator{}, nil, false, nil); err != nil {
 		t.Errorf("%s: Failed to parse Bidders from request: %v", context, err)
 		return nil
 	} else {
@@ -2384,6 +2387,8 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 				mockResponses: map[string]bidderResponse{string(bidderName): spec.MockResponse},
 			}
 			bidderInfos[string(bidderName)] = config.BidderInfo{ModifyingVastXmlAllowed: spec.ModifyingVastXmlAllowed}
+		} else {
+			bidderInfos[string(bidderName)] = config.BidderInfo{}
 		}
 	}
 
@@ -2433,6 +2438,12 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 	}
 
 	metricsEngine := metricsConf.NewMetricsEngine(&config.Configuration{}, openrtb_ext.CoreBidderNames(), nil, nil)
+	paramValidator, err := openrtb_ext.NewBidderParamsValidator("../static/bidder-params")
+	if err != nil {
+		t.Fatalf("Failed to create params validator: %v", error)
+	}
+	bidderMap := GetActiveBidders(bidderInfos)
+	requestValidator := ortb.NewRequestValidator(bidderMap, map[string]string{}, paramValidator)
 	requestSplitter := requestSplitter{
 		bidderToSyncerKey: bidderToSyncerKey,
 		me:                metricsEngine,
@@ -2440,6 +2451,7 @@ func newExchangeForTests(t *testing.T, filename string, expectations map[string]
 		gdprPermsBuilder:  gdprPermsBuilder,
 		hostSChainNode:    hostSChainNode,
 		bidderInfo:        bidderInfos,
+		requestValidator:  requestValidator,
 	}
 
 	return &exchange{
@@ -4565,7 +4577,7 @@ func TestPassExperimentConfigsToHoldAuction(t *testing.T) {
 		},
 	}.Builder
 
-	e := NewExchange(adapters, nil, cfg, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &signer, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
+	e := NewExchange(adapters, nil, cfg, &mockRequestValidator{}, map[string]usersync.Syncer{}, &metricsConf.NilMetricsEngine{}, biddersInfo, gdprPermsBuilder, currencyConverter, nilCategoryFetcher{}, &signer, macros.NewStringIndexBasedReplacer(), nil).(*exchange)
 
 	// Define mock incoming bid requeset
 	mockBidRequest := &openrtb2.BidRequest{
@@ -6332,3 +6344,11 @@ func TestBidsToUpdate(t *testing.T) {
 		})
 	}
 }
+
+type mockRequestValidator struct {
+	errors []error
+}
+
+func (mrv *mockRequestValidator) ValidateImp(imp *openrtb_ext.ImpWrapper, index int, aliases map[string]string, hasStoredResponses bool, storedBidResponses stored_responses.ImpBidderStoredResp) []error {
+	return mrv.errors
+}
diff --git a/exchange/exchangetest/imp-fpd-invalid.json b/exchange/exchangetest/imp-fpd-invalid.json
new file mode 100644
index 00000000..6d0bd9b9
--- /dev/null
+++ b/exchange/exchangetest/imp-fpd-invalid.json
@@ -0,0 +1,55 @@
+{
+    "requestType": "openrtb2-web",
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [
+                {
+                    "id": "imp-id-1",
+                    "banner": {
+                        "format": [
+                            {
+                                "w": 300,
+                                "h": 600
+                            }
+                        ]
+                    },
+                    "ext": {
+                        "prebid": {
+                            "bidder": {
+                                "appnexus": {
+                                    "placementId": 1
+                                },
+                                "audienceNetwork": {
+                                    "placementId": "some-placement"
+                                }
+                            },
+                            "imp": {
+                                "appnexus": {
+                                    "id": "imp-id-1-appnexus"
+                                },
+                                "audienceNetwork": {
+                                    "banner": {
+                                        "format": [
+                                            {
+                                                "w": -1,
+                                                "h": -1
+                                            }
+                                        ]
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            ]
+        }
+    },
+    "outgoingRequests": {},
+    "response": {
+        "error": "merging bidder imp first party data for imp imp-id-1 results in an invalid imp: [request.imp[0].banner.format[0].w must be a positive number]"
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/imp-fpd-multiple-bidders.json b/exchange/exchangetest/imp-fpd-multiple-bidders.json
new file mode 100644
index 00000000..411435d9
--- /dev/null
+++ b/exchange/exchangetest/imp-fpd-multiple-bidders.json
@@ -0,0 +1,202 @@
+{
+    "requestType": "openrtb2-web",
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [
+                {
+                    "id": "imp-id-1",
+                    "banner": {
+                        "format": [
+                            {
+                                "w": 300,
+                                "h": 600
+                            }
+                        ]
+                    },
+                    "ext": {
+                        "prebid": {
+                            "bidder": {
+                                "appnexus": {
+                                    "placementId": 1
+                                },
+                                "audienceNetwork": {
+                                    "placementId": "some-placement"
+                                }
+                            },
+                            "imp": {
+                                "appnexus": {
+                                    "id": "imp-id-1-appnexus"
+                                },
+                                "audienceNetwork": {
+                                    "banner": {
+                                        "format": [
+                                            {
+                                                "w": 3000,
+                                                "h": 6000
+                                            }
+                                        ]
+                                    }
+                                },
+                                "rubicon": {
+                                    "banner": {
+                                        "w": 150,
+                                        "h": 300
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            ]
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [
+                        {
+                            "id": "imp-id-1-appnexus",
+                            "banner": {
+                                "format": [
+                                    {
+                                        "w": 300,
+                                        "h": 600
+                                    }
+                                ]
+                            },
+                            "ext": {
+                                "bidder": {
+                                    "placementId": 1
+                                }
+                            }
+                        }
+                    ]
+                }
+            },
+            "mockResponse": {
+                "pbsSeatBids": [
+                    {
+                        "pbsBids": [
+                            {
+                                "ortbBid": {
+                                    "id": "apn-bid",
+                                    "impid": "imp-id-1-appnexus",
+                                    "price": 0.3,
+                                    "w": 300,
+                                    "h": 600,
+                                    "crid": "creative-1"
+                                },
+                                "bidType": "banner"
+                            }
+                        ],
+                        "seat": "appnexus"
+                    }
+                ]
+            }
+        },
+        "audienceNetwork": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [
+                        {
+                            "id": "imp-id-1",
+                            "banner": {
+                                "format": [
+                                    {
+                                        "w": 3000,
+                                        "h": 6000
+                                    }
+                                ]
+                            },
+                            "ext": {
+                                "bidder": {
+                                    "placementId": "some-placement"
+                                }
+                            }
+                        }
+                    ]
+                }
+            },
+            "mockResponse": {
+                "pbsSeatBids": [
+                    {
+                        "pbsBids": [
+                            {
+                                "ortbBid": {
+                                    "id": "apn-bid",
+                                    "impid": "imp-id-1",
+                                    "price": 0.3,
+                                    "w": 3000,
+                                    "h": 6000,
+                                    "crid": "creative-1"
+                                },
+                                "bidType": "banner"
+                            }
+                        ],
+                        "seat": "audiencenetwork"
+                    }
+                ]
+            }
+        }
+    },
+    "response": {
+        "bids": {
+            "id": "some-request-id",
+            "seatbid": [
+                {
+                    "seat": "appnexus",
+                    "bid": [
+                        {
+                            "id": "apn-bid",
+                            "impid": "imp-id-1-appnexus",
+                            "price": 0.3,
+                            "w": 300,
+                            "h": 600,
+                            "crid": "creative-1",
+                            "ext": {
+                                "origbidcpm": 0.3,
+                                "prebid": {
+                                    "meta": {},
+                                    "type": "banner"
+                                }
+                            }
+                        }
+                    ]
+                },
+                {
+                    "seat": "audiencenetwork",
+                    "bid": [
+                        {
+                            "id": "apn-bid",
+                            "impid": "imp-id-1",
+                            "price": 0.3,
+                            "w": 3000,
+                            "h": 6000,
+                            "crid": "creative-1",
+                            "ext": {
+                                "origbidcpm": 0.3,
+                                "prebid": {
+                                    "meta": {},
+                                    "type": "banner"
+                                }
+                            }
+                        }
+                    ]
+                }
+            ]
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/exchangetest/imp-fpd-multiple-imp.json b/exchange/exchangetest/imp-fpd-multiple-imp.json
new file mode 100644
index 00000000..902a071b
--- /dev/null
+++ b/exchange/exchangetest/imp-fpd-multiple-imp.json
@@ -0,0 +1,288 @@
+{
+    "requestType": "openrtb2-web",
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [
+                {
+                    "id": "imp-id-1",
+                    "banner": {
+                        "w": 100,
+                        "format": [
+                            {
+                                "w": 300,
+                                "h": 600
+                            }
+                        ]
+                    },
+                    "ext": {
+                        "prebid": {
+                            "bidder": {
+                                "appnexus": {
+                                    "placementId": 1
+                                }
+                            },
+                            "imp": {
+                                "appnexus": {
+                                    "banner": {
+                                        "h": 200,
+                                        "format": [
+                                            {
+                                                "w": 3000,
+                                                "h": 6000
+                                            }
+                                        ],
+                                        "btype": [1,2]
+                                    }
+                                }
+                            }
+                        }
+                    }
+                },
+                {
+                    "id": "imp-id-2",
+                    "video": {
+                        "mimes": [
+                            "video/mp3"
+                        ],
+                        "w": 200
+                    },
+                    "ext": {
+                        "prebid": {
+                            "bidder": {
+                                "appnexus": {
+                                    "placementId": 1
+                                }
+                            },
+                            "imp": {
+                                "appnexus": {
+                                    "video": {
+                                        "mimes": [
+                                            "video/mp4"
+                                        ],
+                                        "h": 400
+                                    }
+                                }
+                            }
+                        }
+                    }
+                },
+                {
+                    "id": "imp-id-3",
+                    "audio": {
+                        "mimes": ["audio/mp4"],
+                        "minduration": 1,
+                        "maxduration": 10,
+                        "durfloors": [{
+                            "mindur": 1,
+                            "maxdur": 10,
+                            "bidfloor": 10.0
+                        }]
+                    },
+                    "native": {
+                        "request": "{\"ver\":\"1.1\",\"layout\":1,\"adunit\":2,\"plcmtcnt\":6,\"plcmttype\":4,\"assets\":[{\"id\":1,\"required\":1,\"title\":{\"len\":75}},{\"id\":2,\"required\":1,\"img\":{\"wmin\":492,\"hmin\":328,\"type\":3,\"mimes\":[\"image/jpeg\",\"image/jpg\",\"image/png\"]}},{\"id\":4,\"data\":{\"type\":6}},{\"id\":5,\"data\":{\"type\":7}},{\"id\":6,\"data\":{\"type\":1,\"len\":20}}]}",
+                        "ver": "1.1"
+                    },
+                    "ext": {
+                        "prebid": {
+                            "bidder": {
+                                "appnexus": {
+                                    "placementId": 1
+                                }
+                            },
+                            "imp": {
+                                "appnexus": {
+                                    "audio": {
+                                        "maxduration": 20,
+                                        "durfloors": [{
+                                            "maxdur": 30
+                                        }],
+                                        "poddur": 40
+                                    },
+                                    "native": {
+                                        "ver": "2.2",
+                                        "ctype": 1
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            ]
+        }
+    },
+    "outgoingRequests": {
+        "appnexus": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [
+                        {
+                            "id": "imp-id-1",
+                            "banner": {
+                                "w": 100,
+                                "h": 200,
+                                "format": [
+                                    {
+                                        "w": 3000,
+                                        "h": 6000
+                                    }
+                                ],
+                                "btype": [1,2]
+                            },
+                            "ext": {
+                                "bidder": {
+                                    "placementId": 1
+                                }
+                            }
+                        },
+                        {
+                            "id": "imp-id-2",
+                            "video": {
+                                "mimes": [
+                                    "video/mp4"
+                                ],
+                                "w": 200,
+                                "h": 400
+                            },
+                            "ext": {
+                                "bidder": {
+                                    "placementId": 1
+                                }
+                            }
+                        },
+                        {
+                            "id": "imp-id-3",
+                            "audio": {
+                                "mimes": ["audio/mp4"],
+                                "minduration": 1,
+                                "maxduration": 20,
+                                "durfloors": [{
+                                    "mindur": 1,
+                                    "maxdur": 30,
+                                    "bidfloor": 10.0
+                                }],
+                                "poddur": 40
+                            },
+                            "native": {
+                                "request": "{\"ver\":\"1.1\",\"layout\":1,\"adunit\":2,\"plcmttype\":4,\"plcmtcnt\":6,\"assets\":[{\"id\":1,\"required\":1,\"title\":{\"len\":75}},{\"id\":2,\"required\":1,\"img\":{\"type\":3,\"wmin\":492,\"hmin\":328,\"mimes\":[\"image/jpeg\",\"image/jpg\",\"image/png\"]}},{\"id\":4,\"data\":{\"type\":6}},{\"id\":5,\"data\":{\"type\":7}},{\"id\":6,\"data\":{\"type\":1,\"len\":20}}]}",
+                                "ver": "2.2",
+                                "ctype": 1
+                            },
+                            "ext": {
+                                "bidder": {
+                                    "placementId": 1
+                                }
+                            }
+                        }
+                    ]
+                }
+            },
+            "mockResponse": {
+                "pbsSeatBids": [
+                    {
+                        "pbsBids": [
+                            {
+                                "ortbBid": {
+                                    "id": "apn-bid-1",
+                                    "impid": "imp-id-1",
+                                    "price": 0.1,
+                                    "w": 3000,
+                                    "h": 6000,
+                                    "crid": "creative-1"
+                                },
+                                "bidType": "banner"
+                            },
+                            {
+                                "ortbBid": {
+                                    "id": "apn-bid-2",
+                                    "impid": "imp-id-2",
+                                    "price": 0.2,
+                                    "w": 200,
+                                    "h": 400,
+                                    "crid": "creative-2"
+                                },
+                                "bidType": "video"
+                            },
+                            {
+                                "ortbBid": {
+                                    "id": "apn-bid-3",
+                                    "impid": "imp-id-3",
+                                    "price": 0.3,
+                                    "w": 300,
+                                    "h": 600,
+                                    "crid": "creative-3"
+                                },
+                                "bidType": "native"
+                            }
+                        ],
+                        "seat": "appnexus"
+                    }
+                ]
+            }
+        }
+    },
+    "response": {
+        "bids": {
+            "id": "some-request-id",
+            "seatbid": [
+                {
+                    "seat": "appnexus",
+                    "bid": [
+                        {
+                            "id": "apn-bid-1",
+                            "impid": "imp-id-1",
+                            "price": 0.1,
+                            "w": 3000,
+                            "h": 6000,
+                            "crid": "creative-1",
+                            "ext": {
+                                "origbidcpm": 0.1,
+                                "prebid": {
+                                    "meta": {},
+                                    "type": "banner"
+                                }
+                            }
+                        },
+                        {
+                            "id": "apn-bid-2",
+                            "impid": "imp-id-2",
+                            "price": 0.2,
+                            "w": 200,
+                            "h": 400,
+                            "crid": "creative-2",
+                            "ext": {
+                                "origbidcpm": 0.2,
+                                "prebid": {
+                                    "meta": {},
+                                    "type": "video"
+                                }
+                            }
+                        },
+                        {
+                            "id": "apn-bid-3",
+                            "impid": "imp-id-3",
+                            "price": 0.3,
+                            "w": 300,
+                            "h": 600,
+                            "crid": "creative-3",
+                            "ext": {
+                                "origbidcpm": 0.3,
+                                "prebid": {
+                                    "meta": {},
+                                    "type": "native"
+                                }
+                            }
+                        }
+                    ]
+                }
+            ]
+        }
+    }
+}
\ No newline at end of file
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index 69195736..c22fc091 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -93,10 +93,11 @@ func assertReq(t *testing.T, bidderRequests []BidderRequest,
 
 func TestSplitImps(t *testing.T) {
 	testCases := []struct {
-		description   string
-		givenImps     []openrtb2.Imp
-		expectedImps  map[string][]openrtb2.Imp
-		expectedError string
+		description     string
+		givenImps       []openrtb2.Imp
+		validatorErrors []error
+		expectedImps    map[string][]openrtb2.Imp
+		expectedError   string
 	}{
 		{
 			description:   "Nil",
@@ -209,10 +210,105 @@ func TestSplitImps(t *testing.T) {
 			},
 			expectedError: "invalid json for imp[0]: do not know how to skip: 109",
 		},
+		{
+			description: "Malformed imp.ext.prebid.imp",
+			givenImps: []openrtb2.Imp{
+				{ID: "imp1", Ext: json.RawMessage(`{"prebid": {"imp": malformed}}`)},
+			},
+			expectedError: "invalid json for imp[0]: do not know how to skip: 109",
+		},
+		{
+			description: "valid FPD at imp.ext.prebid.imp for valid bidder",
+			givenImps: []openrtb2.Imp{
+				{
+					ID: "imp1",
+					Banner: &openrtb2.Banner{
+						Format: []openrtb2.Format{
+							{
+								W: 10,
+								H: 20,
+							},
+						},
+					},
+					Ext: json.RawMessage(`{"prebid":{"bidder":{"bidderA":{"imp1paramA":"imp1valueA"}},"imp":{"bidderA":{"id":"impFPD", "banner":{"format":[{"h":30}]}}}}}`),
+				},
+			},
+			expectedImps: map[string][]openrtb2.Imp{
+				"bidderA": {
+					{
+						ID: "impFPD",
+						Banner: &openrtb2.Banner{
+							Format: []openrtb2.Format{
+								{
+									W: 10,
+									H: 30,
+								},
+							},
+						},
+						Ext: json.RawMessage(`{"bidder":{"imp1paramA":"imp1valueA"}}`),
+					},
+				},
+			},
+			expectedError: "",
+		},
+		{
+			description: "valid FPD at imp.ext.prebid.imp for unknown bidder",
+			givenImps: []openrtb2.Imp{
+				{
+					ID: "imp1",
+					Banner: &openrtb2.Banner{
+						Format: []openrtb2.Format{
+							{
+								W: 10,
+								H: 20,
+							},
+						},
+					},
+					Ext: json.RawMessage(`{"prebid":{"bidder":{"bidderB":{"imp1paramB":"imp1valueB"}},"imp":{"bidderA":{"id":"impFPD", "banner":{"format":[{"h":30}]}}}}}`),
+				},
+			},
+			expectedImps: map[string][]openrtb2.Imp{
+				"bidderB": {
+					{
+						ID: "imp1",
+						Banner: &openrtb2.Banner{
+							Format: []openrtb2.Format{
+								{
+									W: 10,
+									H: 20,
+								},
+							},
+						},
+						Ext: json.RawMessage(`{"bidder":{"imp1paramB":"imp1valueB"}}`),
+					},
+				},
+			},
+			expectedError: "",
+		},
+		{
+			description: "invalid FPD at imp.ext.prebid.imp for valid bidder",
+			givenImps: []openrtb2.Imp{
+				{
+					ID: "imp1",
+					Banner: &openrtb2.Banner{
+						Format: []openrtb2.Format{
+							{
+								W: 10,
+								H: 20,
+							},
+						},
+					},
+					Ext: json.RawMessage(`{"prebid":{"bidder":{"bidderA":{"imp1paramA":"imp1valueA"}},"imp":{"bidderA":{"id":"impFPD", "banner":{"format":[{"w":0,"h":0}]}}}}}`),
+				},
+			},
+			validatorErrors: []error{errors.New("some error")},
+			expectedImps:    nil,
+			expectedError:   "merging bidder imp first party data for imp imp1 results in an invalid imp: [some error]",
+		},
 	}
 
 	for _, test := range testCases {
-		imps, err := splitImps(test.givenImps)
+		imps, err := splitImps(test.givenImps, &mockRequestValidator{errors: test.validatorErrors}, nil, false, nil)
 
 		if test.expectedError == "" {
 			assert.NoError(t, err, test.description+":err")
@@ -224,6 +320,201 @@ func TestSplitImps(t *testing.T) {
 	}
 }
 
+func TestMergeImpFPD(t *testing.T) {
+	imp1 := &openrtb2.Imp{
+		ID: "imp1",
+		Banner: &openrtb2.Banner{
+			W: ptrutil.ToPtr[int64](200),
+			H: ptrutil.ToPtr[int64](400),
+		},
+	}
+
+	tests := []struct {
+		description string
+		imp         *openrtb2.Imp
+		fpd         json.RawMessage
+		wantImp     *openrtb2.Imp
+		wantError   bool
+	}{
+		{
+			description: "nil",
+			imp:         nil,
+			fpd:         nil,
+			wantImp:     nil,
+			wantError:   true,
+		},
+		{
+			description: "nil_fpd",
+			imp:         imp1,
+			fpd:         nil,
+			wantImp:     imp1,
+			wantError:   true,
+		},
+		{
+			description: "empty_fpd",
+			imp:         imp1,
+			fpd:         json.RawMessage(`{}`),
+			wantImp:     imp1,
+			wantError:   false,
+		},
+		{
+			description: "nil_imp",
+			imp:         nil,
+			fpd:         json.RawMessage(`{}`),
+			wantImp:     nil,
+			wantError:   true,
+		},
+		{
+			description: "zero_value_imp",
+			imp:         &openrtb2.Imp{},
+			fpd:         json.RawMessage(`{}`),
+			wantImp:     &openrtb2.Imp{},
+			wantError:   false,
+		},
+		{
+			description: "invalid_json_on_existing_imp",
+			imp: &openrtb2.Imp{
+				Ext: json.RawMessage(`malformed`),
+			},
+			fpd: json.RawMessage(`{"ext": {"a":1}}`),
+			wantImp: &openrtb2.Imp{
+				Ext: json.RawMessage(`malformed`),
+			},
+			wantError: true,
+		},
+		{
+			description: "invalid_json_in_fpd",
+			imp: &openrtb2.Imp{
+				Ext: json.RawMessage(`{"ext": {"a":1}}`),
+			},
+			fpd: json.RawMessage(`malformed`),
+			wantImp: &openrtb2.Imp{
+				Ext: json.RawMessage(`{"ext": {"a":1}}`),
+			},
+			wantError: true,
+		},
+		{
+			description: "override_everything",
+			imp: &openrtb2.Imp{
+				ID:     "id1",
+				Metric: []openrtb2.Metric{{Type: "type1", Value: 1, Vendor: "vendor1"}},
+				Banner: &openrtb2.Banner{
+					W: ptrutil.ToPtr[int64](1),
+					H: ptrutil.ToPtr[int64](2),
+					Format: []openrtb2.Format{
+						{
+							W:   10,
+							H:   20,
+							Ext: json.RawMessage(`{"formatkey1":"formatval1"}`),
+						},
+					},
+				},
+				Instl:    1,
+				BidFloor: 1,
+				Ext:      json.RawMessage(`{"cool":"test"}`),
+			},
+			fpd: json.RawMessage(`{"id": "id2", "metric": [{"type":"type2", "value":2, "vendor":"vendor2"}], "banner": {"w":100, "h": 200, "format": [{"w":1000, "h":2000, "ext":{"formatkey1":"formatval2"}}]}, "instl":2, "bidfloor":2, "ext":{"cool":"test2"} }`),
+			wantImp: &openrtb2.Imp{
+				ID:     "id2",
+				Metric: []openrtb2.Metric{{Type: "type2", Value: 2, Vendor: "vendor2"}},
+				Banner: &openrtb2.Banner{
+					W: ptrutil.ToPtr[int64](100),
+					H: ptrutil.ToPtr[int64](200),
+					Format: []openrtb2.Format{
+						{
+							W:   1000,
+							H:   2000,
+							Ext: json.RawMessage(`{"formatkey1":"formatval2"}`),
+						},
+					},
+				},
+				Instl:    2,
+				BidFloor: 2,
+				Ext:      json.RawMessage(`{"cool":"test2"}`),
+			},
+		},
+		{
+			description: "override_partial_simple",
+			imp:         imp1,
+			fpd:         json.RawMessage(`{"id": "456", "banner": {"format": [{"w":1, "h":2}]} }`),
+			wantImp: &openrtb2.Imp{
+				ID: "456",
+				Banner: &openrtb2.Banner{
+					W: ptrutil.ToPtr[int64](200),
+					H: ptrutil.ToPtr[int64](400),
+					Format: []openrtb2.Format{
+						{
+							W: 1,
+							H: 2,
+						},
+					},
+				},
+			},
+		},
+		{
+			description: "override_partial_complex",
+			imp: &openrtb2.Imp{
+				ID:     "id1",
+				Metric: []openrtb2.Metric{{Type: "type1", Value: 1, Vendor: "vendor1"}},
+				Banner: &openrtb2.Banner{
+					W: ptrutil.ToPtr[int64](1),
+					H: ptrutil.ToPtr[int64](2),
+					Format: []openrtb2.Format{
+						{
+							W:   10,
+							H:   20,
+							Ext: json.RawMessage(`{"formatkey1":"formatval1", "formatkey2":"formatval2"}`),
+						},
+					},
+				},
+				Instl:        1,
+				TagID:        "tag1",
+				BidFloor:     1,
+				Rwdd:         1,
+				DT:           1,
+				IframeBuster: []string{"buster1", "buster2"},
+				Ext:          json.RawMessage(`{"cool1":"test1", "cool2":"test2"}`),
+			},
+			fpd: json.RawMessage(`{"id": "id2", "metric": [{"type":"type2", "value":2, "vendor":"vendor2"}], "banner": {"w":100, "format": [{"w":1000, "ext":{"formatkey1":"formatval11"}}]}, "instl":2, "bidfloor":2, "ext":{"cool1":"test11"} }`),
+			wantImp: &openrtb2.Imp{
+				ID:     "id2",
+				Metric: []openrtb2.Metric{{Type: "type2", Value: 2, Vendor: "vendor2"}},
+				Banner: &openrtb2.Banner{
+					W: ptrutil.ToPtr[int64](100),
+					H: ptrutil.ToPtr[int64](2),
+					Format: []openrtb2.Format{
+						{
+							W:   1000,
+							H:   20,
+							Ext: json.RawMessage(`{"formatkey1":"formatval11","formatkey2":"formatval2"}`),
+						},
+					},
+				},
+				Instl:        2,
+				TagID:        "tag1",
+				BidFloor:     2,
+				Rwdd:         1,
+				DT:           1,
+				IframeBuster: []string{"buster1", "buster2"},
+				Ext:          json.RawMessage(`{"cool1":"test11","cool2":"test2"}`),
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.description, func(t *testing.T) {
+			err := mergeImpFPD(test.imp, test.fpd, 1)
+			assert.Equal(t, test.wantImp, test.imp)
+
+			if test.wantError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
 func TestCreateSanitizedImpExt(t *testing.T) {
 	testCases := []struct {
 		description       string