Repository: prebid/prebid-server
Pull request #1397 (2020-07-14 06:01:17)

Set OpenRTB DNT From HTTP Header
Addresses https://github.com/prebid/prebid-server/issues/1372


Patch:
diff --git a/adapters/dmx/dmx.go b/adapters/dmx/dmx.go
index 6b4f698d..de33bd39 100644
--- a/adapters/dmx/dmx.go
+++ b/adapters/dmx/dmx.go
@@ -160,7 +160,7 @@ func (adapter *DmxAdapter) MakeRequests(request *openrtb.BidRequest, req *adapte
 	}
 
 	headers := http.Header{}
-	headers.Add("Content-Type", "Application/json;charset=utf-8")
+	headers.Add("Content-Type", "application/json;charset=utf-8")
 	reqBidder := &adapters.RequestData{
 		Method:  "POST",
 		Uri:     adapter.endpoint + addParams(sellerId), //adapter.endpoint,
diff --git a/config/config.go b/config/config.go
index f33dba69..2e7f875b 100755
--- a/config/config.go
+++ b/config/config.go
@@ -793,7 +793,7 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("adapters.conversant.endpoint", "http://api.hb.ad.cpe.dotomi.com/cvx/server/hb/ortb/25")
 	v.SetDefault("adapters.cpmstar.endpoint", "https://server.cpmstar.com/openrtbbidrq.aspx")
 	v.SetDefault("adapters.datablocks.endpoint", "http://{{.Host}}/openrtb2?sid={{.SourceId}}")
-	v.SetDefault("adapters.dmx.endpoint", "https://dmx.districtm.io/b/v2")
+	v.SetDefault("adapters.dmx.endpoint", "https://dmx-direct.districtm.io/b/v2")
 	v.SetDefault("adapters.emx_digital.endpoint", "https://hb.emxdgt.com")
 	v.SetDefault("adapters.engagebdr.endpoint", "http://dsp.bnmla.com/hb")
 	v.SetDefault("adapters.eplanning.endpoint", "http://rtb.e-planning.net/pbs/1")
@@ -804,7 +804,7 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("adapters.improvedigital.endpoint", "http://ad.360yield.com/pbs")
 	v.SetDefault("adapters.ix.endpoint", "http://appnexus-us-east.lb.indexww.com/transbidder?p=184932")
 	v.SetDefault("adapters.kidoz.endpoint", "http://prebid-adapter.kidoz.net/openrtb2/auction?src=prebid-server")
-	v.SetDefault("adapters.kubient.endpoint", "http://kbntx.ch/prebid")
+	v.SetDefault("adapters.kubient.endpoint", "https://kssp.kbntx.ch/prebid")
 	v.SetDefault("adapters.lifestreet.endpoint", "https://prebid.s2s.lfstmedia.com/adrequest")
 	v.SetDefault("adapters.lockerdome.endpoint", "https://lockerdome.com/ladbid/prebidserver/openrtb2")
 	v.SetDefault("adapters.logicad.endpoint", "https://pbs.ladsp.com/adrequest/prebidserver")
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index 20acc2ae..86186fa8 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -39,6 +39,12 @@ import (
 
 const storedRequestTimeoutMillis = 50
 
+var (
+	dntKey      string = http.CanonicalHeaderKey("DNT")
+	dntDisabled int8   = 0
+	dntEnabled  int8   = 1
+)
+
 func NewEndpoint(ex exchange.Exchange, validator openrtb_ext.BidderParamValidator, requestsById stored_requests.Fetcher, categories stored_requests.CategoryFetcher, cfg *config.Configuration, met pbsmetrics.MetricsEngine, pbsAnalytics analytics.PBSAnalyticsModule, disabledBidders map[string]string, defReqJSON []byte, bidderMap map[string]openrtb_ext.BidderName) (httprouter.Handle, error) {
 
 	if ex == nil || validator == nil || requestsById == nil || cfg == nil || met == nil {
@@ -290,6 +296,10 @@ func (deps *endpointDeps) validateRequest(req *openrtb.BidRequest) []error {
 		if err := validateBidAdjustmentFactors(bidExt.Prebid.BidAdjustmentFactors, aliases); err != nil {
 			return []error{err}
 		}
+
+		if err := validateSChains(bidExt); err != nil {
+			return []error{err}
+		}
 	}
 
 	if (req.Site == nil && req.App == nil) || (req.Site != nil && req.App != nil) {
@@ -362,6 +372,11 @@ func validateBidAdjustmentFactors(adjustmentFactors map[string]float64, aliases
 	return nil
 }
 
+func validateSChains(req *openrtb_ext.ExtRequest) error {
+	_, err := exchange.BidderToPrebidSChains(req)
+	return err
+}
+
 func (deps *endpointDeps) validateImp(imp *openrtb.Imp, aliases map[string]string, index int) []error {
 	if imp.ID == "" {
 		return []error{fmt.Errorf("request.imp[%d] missing required field: \"id\"", index)}
@@ -955,6 +970,8 @@ func (deps *endpointDeps) setFieldsImplicitly(httpReq *http.Request, bidReq *ope
 func setDeviceImplicitly(httpReq *http.Request, bidReq *openrtb.BidRequest, ipValidtor iputil.IPValidator) {
 	setIPImplicitly(httpReq, bidReq, ipValidtor)
 	setUAImplicitly(httpReq, bidReq)
+	setDoNotTrackImplicitly(httpReq, bidReq)
+
 }
 
 // setAuctionTypeImplicitly sets the auction type to 1 if it wasn't on the request,
@@ -1183,6 +1200,24 @@ func setUAImplicitly(httpReq *http.Request, bidReq *openrtb.BidRequest) {
 	}
 }
 
+func setDoNotTrackImplicitly(httpReq *http.Request, bidReq *openrtb.BidRequest) {
+	if bidReq.Device == nil || bidReq.Device.DNT == nil {
+		dnt := httpReq.Header.Get(dntKey)
+		if dnt == "0" || dnt == "1" {
+			if bidReq.Device == nil {
+				bidReq.Device = &openrtb.Device{}
+			}
+
+			switch dnt {
+			case "0":
+				bidReq.Device.DNT = &dntDisabled
+			case "1":
+				bidReq.Device.DNT = &dntEnabled
+			}
+		}
+	}
+}
+
 // parseUserID gets this user's ID for the host machine, if it exists.
 func parseUserID(cfg *config.Configuration, httpReq *http.Request) (string, bool) {
 	if hostCookie, err := httpReq.Cookie(cfg.HostCookie.CookieName); hostCookie != nil && err == nil {
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/supplementary/site-has-dnt.json b/endpoints/openrtb2/sample-requests/valid-whole/supplementary/site-has-dnt.json
new file mode 100644
index 00000000..b1fae20a
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/supplementary/site-has-dnt.json
@@ -0,0 +1,45 @@
+{
+    "id": "some-request-id",
+    "site": {
+      "page": "test.somepage.com"
+    },
+    "device": {
+        "dnt": 1
+    },
+    "imp": [
+      {
+        "id": "my-imp-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "pmp": {
+          "deals": [
+            {
+              "id": "some-deal-id"
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "ext": {
+      "prebid": {
+        "targeting": {
+          "pricegranularity": "low"
+        },
+        "cache": {
+          "bids": {}
+        }
+      }
+    }
+  }
+  
\ No newline at end of file
diff --git a/exchange/utils.go b/exchange/utils.go
index 96c00ec0..4de985ec 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -28,6 +28,29 @@ type cleanMetrics struct {
 	gdprTcfVersion int
 }
 
+func BidderToPrebidSChains(req *openrtb_ext.ExtRequest) (map[string]*openrtb_ext.ExtRequestPrebidSChainSChain, error) {
+	bidderToSChains := make(map[string]*openrtb_ext.ExtRequestPrebidSChainSChain)
+
+	if len(req.Prebid.SChains) == 0 {
+		return bidderToSChains, nil
+	}
+
+	for _, schainWrapper := range req.Prebid.SChains {
+		if schainWrapper != nil && len(schainWrapper.Bidders) > 0 {
+			for _, bidder := range schainWrapper.Bidders {
+				if _, present := bidderToSChains[bidder]; present {
+					return nil, fmt.Errorf("request.ext.prebid.schains contains multiple schains for bidder %s; "+
+						"it must contain no more than one per bidder.", bidder)
+				} else {
+					bidderToSChains[bidder] = &schainWrapper.SChain
+				}
+			}
+		}
+	}
+
+	return bidderToSChains, nil
+}
+
 // cleanOpenRTBRequests splits the input request into requests which are sanitized for each bidder. Intended behavior is:
 //
 //   1. BidRequest.Imp[].Ext will only contain the "prebid" field and a "bidder" field which has the params for the intended Bidder.
@@ -103,12 +126,35 @@ func cleanOpenRTBRequests(ctx context.Context,
 	return
 }
 
-func splitBidRequest(req *openrtb.BidRequest, impsByBidder map[string][]openrtb.Imp, aliases map[string]string, usersyncs IdFetcher, blabels map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels, labels pbsmetrics.Labels) (map[openrtb_ext.BidderName]*openrtb.BidRequest, []error) {
+func splitBidRequest(req *openrtb.BidRequest,
+	impsByBidder map[string][]openrtb.Imp,
+	aliases map[string]string,
+	usersyncs IdFetcher,
+	blabels map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels,
+	labels pbsmetrics.Labels) (map[openrtb_ext.BidderName]*openrtb.BidRequest, []error) {
+
 	requestsByBidder := make(map[openrtb_ext.BidderName]*openrtb.BidRequest, len(impsByBidder))
 	explicitBuyerUIDs, err := extractBuyerUIDs(req.User)
 	if err != nil {
 		return nil, []error{err}
 	}
+
+	var requestExt openrtb_ext.ExtRequest
+	var sChainsByBidder map[string]*openrtb_ext.ExtRequestPrebidSChainSChain
+	if len(req.Ext) > 0 {
+		err := json.Unmarshal(req.Ext, &requestExt)
+		if err != nil {
+			return nil, []error{err}
+		}
+
+		sChainsByBidder, err = BidderToPrebidSChains(&requestExt)
+		if err != nil {
+			return nil, []error{err}
+		}
+	} else {
+		sChainsByBidder = make(map[string]*openrtb_ext.ExtRequestPrebidSChainSChain)
+	}
+
 	for bidder, imps := range impsByBidder {
 		reqCopy := *req
 		coreBidder := resolveBidder(bidder, aliases)
@@ -128,11 +174,57 @@ func splitBidRequest(req *openrtb.BidRequest, impsByBidder map[string][]openrtb.
 			blabels[coreBidder].CookieFlag = pbsmetrics.CookieFlagYes
 		}
 		reqCopy.Imp = imps
+
+		prepareSource(&reqCopy, bidder, sChainsByBidder)
+		prepareExt(&reqCopy, &requestExt)
+
 		requestsByBidder[openrtb_ext.BidderName(bidder)] = &reqCopy
 	}
 	return requestsByBidder, nil
 }
 
+func prepareExt(req *openrtb.BidRequest, unpackedExt *openrtb_ext.ExtRequest) {
+	if len(req.Ext) == 0 {
+		return
+	}
+	extCopy := *unpackedExt
+	extCopy.Prebid.SChains = nil
+	reqExt, err := json.Marshal(extCopy)
+	if err == nil {
+		req.Ext = reqExt
+	}
+}
+
+func prepareSource(req *openrtb.BidRequest, bidder string, sChainsByBidder map[string]*openrtb_ext.ExtRequestPrebidSChainSChain) {
+	const sChainWildCard = "*"
+	var selectedSChain *openrtb_ext.ExtRequestPrebidSChainSChain
+
+	wildCardSChain := sChainsByBidder[sChainWildCard]
+	bidderSChain := sChainsByBidder[bidder]
+
+	// source should not be modified
+	if bidderSChain == nil && wildCardSChain == nil {
+		return
+	}
+
+	if bidderSChain != nil {
+		selectedSChain = bidderSChain
+	} else {
+		selectedSChain = wildCardSChain
+	}
+
+	// set source
+	var source openrtb.Source
+	schain := openrtb_ext.ExtRequestPrebidSChain{
+		SChain: *selectedSChain,
+	}
+	sourceExt, err := json.Marshal(schain)
+	if err == nil {
+		source.Ext = sourceExt
+		req.Source = &source
+	}
+}
+
 // extractBuyerUIDs parses the values from user.ext.prebid.buyeruids, and then deletes those values from the ext.
 // This prevents a Bidder from using these values to figure out who else is involved in the Auction.
 func extractBuyerUIDs(user *openrtb.User) (map[string]string, error) {
diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go
index 25b5c881..86388f60 100644
--- a/openrtb_ext/request.go
+++ b/openrtb_ext/request.go
@@ -12,12 +12,43 @@ type ExtRequest struct {
 
 // ExtRequestPrebid defines the contract for bidrequest.ext.prebid
 type ExtRequestPrebid struct {
-	Aliases              map[string]string      `json:"aliases,omitempty"`
-	BidAdjustmentFactors map[string]float64     `json:"bidadjustmentfactors,omitempty"`
-	Cache                *ExtRequestPrebidCache `json:"cache,omitempty"`
-	StoredRequest        *ExtStoredRequest      `json:"storedrequest,omitempty"`
-	Targeting            *ExtRequestTargeting   `json:"targeting,omitempty"`
-	SupportDeals         bool                   `json:"supportdeals,omitempty"`
+	Aliases              map[string]string         `json:"aliases,omitempty"`
+	BidAdjustmentFactors map[string]float64        `json:"bidadjustmentfactors,omitempty"`
+	Cache                *ExtRequestPrebidCache    `json:"cache,omitempty"`
+	SChains              []*ExtRequestPrebidSChain `json:"schains,omitempty"`
+	StoredRequest        *ExtStoredRequest         `json:"storedrequest,omitempty"`
+	Targeting            *ExtRequestTargeting      `json:"targeting,omitempty"`
+	SupportDeals         bool                      `json:"supportdeals,omitempty"`
+}
+
+// ExtRequestPrebid defines the contract for bidrequest.ext.prebid.schains
+type ExtRequestPrebidSChain struct {
+	Bidders []string                     `json:"bidders,omitempty"`
+	SChain  ExtRequestPrebidSChainSChain `json:"schain"`
+}
+
+// ExtRequestPrebidSChainSChain defines the contract for bidrequest.ext.prebid.schains[i].schain
+type ExtRequestPrebidSChainSChain struct {
+	Complete int                                 `json:"complete"`
+	Nodes    []*ExtRequestPrebidSChainSChainNode `json:"nodes"`
+	Ver      string                              `json:"ver"`
+	Ext      json.RawMessage                     `json:"ext,omitempty"`
+}
+
+// ExtRequestPrebidSChainSChainNode defines the contract for bidrequest.ext.prebid.schains[i].schain[i].nodes
+type ExtRequestPrebidSChainSChainNode struct {
+	ASI    string          `json:"asi"`
+	SID    string          `json:"sid"`
+	RID    string          `json:"rid,omitempty"`
+	Name   string          `json:"name,omitempty"`
+	Domain string          `json:"domain,omitempty"`
+	HP     int             `json:"hp"`
+	Ext    json.RawMessage `json:"ext,omitempty"`
+}
+
+// SourceExt defines the contract for bidrequest.source.ext
+type SourceExt struct {
+	SChain ExtRequestPrebidSChainSChain `json:"schain"`
 }
 
 // ExtRequestPrebidCache defines the contract for bidrequest.ext.prebid.cache


Test patch:
diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index 97f0038a..957760c6 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -604,6 +604,189 @@ func TestImplicitIPsEndToEnd(t *testing.T) {
 	}
 }
 
+func TestImplicitDNT(t *testing.T) {
+	var (
+		disabled int8 = 0
+		enabled  int8 = 1
+	)
+	testCases := []struct {
+		description     string
+		dntHeader       string
+		request         openrtb.BidRequest
+		expectedRequest openrtb.BidRequest
+	}{
+		{
+			description:     "Device Missing - Not Set In Header",
+			dntHeader:       "",
+			request:         openrtb.BidRequest{},
+			expectedRequest: openrtb.BidRequest{},
+		},
+		{
+			description: "Device Missing - Set To 0 In Header",
+			dntHeader:   "0",
+			request:     openrtb.BidRequest{},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &disabled,
+				},
+			},
+		},
+		{
+			description: "Device Missing - Set To 1 In Header",
+			dntHeader:   "1",
+			request:     openrtb.BidRequest{},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+		},
+		{
+			description: "Not Set In Request - Not Set In Header",
+			dntHeader:   "",
+			request: openrtb.BidRequest{
+				Device: &openrtb.Device{},
+			},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{},
+			},
+		},
+		{
+			description: "Not Set In Request - Set To 0 In Header",
+			dntHeader:   "0",
+			request: openrtb.BidRequest{
+				Device: &openrtb.Device{},
+			},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &disabled,
+				},
+			},
+		},
+		{
+			description: "Not Set In Request - Set To 1 In Header",
+			dntHeader:   "1",
+			request: openrtb.BidRequest{
+				Device: &openrtb.Device{},
+			},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+		},
+		{
+			description: "Set In Request - Not Set In Header",
+			dntHeader:   "",
+			request: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+		},
+		{
+			description: "Set In Request - Set To 0 In Header",
+			dntHeader:   "0",
+			request: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+		},
+		{
+			description: "Set In Request - Set To 1 In Header",
+			dntHeader:   "1",
+			request: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+			expectedRequest: openrtb.BidRequest{
+				Device: &openrtb.Device{
+					DNT: &enabled,
+				},
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		httpReq := httptest.NewRequest("POST", "/openrtb2/auction", nil)
+		httpReq.Header.Set("DNT", test.dntHeader)
+		setDoNotTrackImplicitly(httpReq, &test.request)
+		assert.Equal(t, test.expectedRequest, test.request)
+	}
+}
+
+func TestImplicitDNTEndToEnd(t *testing.T) {
+	var (
+		disabled int8 = 0
+		enabled  int8 = 1
+	)
+	testCases := []struct {
+		description string
+		reqJSONFile string
+		dntHeader   string
+		expectedDNT *int8
+	}{
+		{
+			description: "Not Set In Request - Not Set In Header",
+			reqJSONFile: "site.json",
+			dntHeader:   "",
+			expectedDNT: nil,
+		},
+		{
+			description: "Not Set In Request - Set To 0 In Header",
+			reqJSONFile: "site.json",
+			dntHeader:   "0",
+			expectedDNT: &disabled,
+		},
+		{
+			description: "Not Set In Request - Set To 1 In Header",
+			reqJSONFile: "site.json",
+			dntHeader:   "1",
+			expectedDNT: &enabled,
+		},
+		{
+			description: "Set In Request - Not Set In Header",
+			reqJSONFile: "site-has-dnt.json",
+			dntHeader:   "",
+			expectedDNT: &enabled, // Hardcoded value in test file.
+		},
+		{
+			description: "Set In Request - Not Overwritten By Header",
+			reqJSONFile: "site-has-dnt.json",
+			dntHeader:   "0",
+			expectedDNT: &enabled, // Hardcoded value in test file.
+		},
+	}
+
+	metrics := pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{})
+	for _, test := range testCases {
+		exchange := &nobidExchange{}
+		endpoint, _ := NewEndpoint(exchange, newParamsValidator(t), &mockStoredReqFetcher{}, empty_fetcher.EmptyFetcher{}, &config.Configuration{MaxRequestSize: maxSize}, metrics, analyticsConf.NewPBSAnalytics(&config.Analytics{}), map[string]string{}, []byte{}, openrtb_ext.BidderMap)
+
+		httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(validRequest(t, test.reqJSONFile)))
+		httpReq.Header.Set("DNT", test.dntHeader)
+
+		endpoint(httptest.NewRecorder(), httpReq, nil)
+
+		result := exchange.gotRequest
+		if !assert.NotEmpty(t, result, test.description+"Request received by the exchange.") {
+			t.FailNow()
+		}
+		assert.Equal(t, test.expectedDNT, result.Device.DNT, test.description+":dnt")
+	}
+}
 func TestImplicitSecure(t *testing.T) {
 	httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(validRequest(t, "site.json")))
 	httpReq.Header.Set(http.CanonicalHeaderKey("X-Forwarded-Proto"), "https")
@@ -1039,6 +1222,53 @@ func TestCCPAInvalid(t *testing.T) {
 	assert.Empty(t, req.Regs.Ext, "Invalid Consent Removed From Request")
 }
 
+func TestSChainInvalid(t *testing.T) {
+	deps := &endpointDeps{
+		&nobidExchange{},
+		newParamsValidator(t),
+		&mockStoredReqFetcher{},
+		empty_fetcher.EmptyFetcher{},
+		empty_fetcher.EmptyFetcher{},
+		&config.Configuration{},
+		pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{}),
+		analyticsConf.NewPBSAnalytics(&config.Analytics{}),
+		map[string]string{},
+		false,
+		[]byte{},
+		openrtb_ext.BidderMap,
+		nil,
+		nil,
+		hardcodedResponseIPValidator{response: true},
+	}
+
+	ui := uint64(1)
+	req := openrtb.BidRequest{
+		ID: "someID",
+		Imp: []openrtb.Imp{
+			{
+				ID: "imp-ID",
+				Banner: &openrtb.Banner{
+					W: &ui,
+					H: &ui,
+				},
+				Ext: json.RawMessage(`{"appnexus": {"placementId": 5667}}`),
+			},
+		},
+		Site: &openrtb.Site{
+			ID: "myID",
+		},
+		Regs: &openrtb.Regs{
+			Ext: json.RawMessage(`{"us_privacy":"abcd"}`),
+		},
+		Ext: json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller1.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller2.com","sid":"00002","rid":"BidRequest2","hp":1}],"ver":"1.0"}}]}}`),
+	}
+
+	errL := deps.validateRequest(&req)
+
+	expectedError := fmt.Errorf("request.ext.prebid.schains contains multiple schains for bidder appnexus; it must contain no more than one per bidder.")
+	assert.ElementsMatch(t, errL, []error{expectedError})
+}
+
 func TestSanitizeRequest(t *testing.T) {
 	testCases := []struct {
 		description  string
diff --git a/exchange/utils_test.go b/exchange/utils_test.go
index e50d0f77..6d66e816 100644
--- a/exchange/utils_test.go
+++ b/exchange/utils_test.go
@@ -135,6 +135,92 @@ func TestCleanOpenRTBRequestsCCPA(t *testing.T) {
 	}
 }
 
+func TestCleanOpenRTBRequestsSChain(t *testing.T) {
+	testCases := []struct {
+		description  string
+		inSourceExt  json.RawMessage
+		inExt        json.RawMessage
+		outSourceExt json.RawMessage
+		outExt       json.RawMessage
+		hasError     bool
+	}{
+		{
+			description:  "Empty root ext and source ext",
+			inSourceExt:  json.RawMessage(``),
+			inExt:        json.RawMessage(``),
+			outSourceExt: json.RawMessage(``),
+			outExt:       json.RawMessage(``),
+			hasError:     false,
+		},
+		{
+			description:  "No schains in root ext and empty source ext",
+			inSourceExt:  json.RawMessage(``),
+			inExt:        json.RawMessage(`{"prebid":{"schains":[]}}`),
+			outSourceExt: json.RawMessage(``),
+			outExt:       json.RawMessage(`{"prebid":{}}`),
+			hasError:     false,
+		},
+		{
+			description:  "Use source schain -- no bidder schain or wildcard schain in ext.prebid.schains",
+			inSourceExt:  json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"example.com","sid":"example1","rid":"ExampleReq1","hp":1}],"ver":"1.0"}}`),
+			inExt:        json.RawMessage(`{"prebid":{"schains":[{"bidders":["bidder1"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}]}}`),
+			outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"example.com","sid":"example1","rid":"ExampleReq1","hp":1}],"ver":"1.0"}}`),
+			outExt:       json.RawMessage(`{"prebid":{}}`),
+			hasError:     false,
+		},
+		{
+			description:  "Use schain for bidder in ext.prebid.schains",
+			inSourceExt:  json.RawMessage(``),
+			inExt:        json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}]}}`),
+			outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}`),
+			outExt:       json.RawMessage(`{"prebid":{}}`),
+			hasError:     false,
+		},
+		{
+			description:  "Use wildcard schain in ext.prebid.schains",
+			inSourceExt:  json.RawMessage(``),
+			inExt:        json.RawMessage(`{"prebid":{"schains":[{"bidders":["*"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}]}}`),
+			outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}`),
+			outExt:       json.RawMessage(`{"prebid":{}}`),
+			hasError:     false,
+		},
+		{
+			description:  "Use schain for bidder in ext.prebid.schains instead of wildcard",
+			inSourceExt:  json.RawMessage(``),
+			inExt:        json.RawMessage(`{"prebid":{"aliases":{"appnexus":"alias1"},"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["*"],"schain":{"complete":1,"nodes":[{"asi":"wildcard.com","sid":"wildcard1","rid":"WildcardReq1","hp":1}],"ver":"1.0"}} ]}}`),
+			outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}`),
+			outExt:       json.RawMessage(`{"prebid":{"aliases":{"appnexus":"alias1"}}}`),
+			hasError:     false,
+		},
+		{
+			description:  "Use source schain -- multiple (two) bidder schains in ext.prebid.schains",
+			inSourceExt:  json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"example.com","sid":"example1","rid":"ExampleReq1","hp":1}],"ver":"1.0"}}`),
+			inExt:        json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller1.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller2.com","sid":"00002","rid":"BidRequest2","hp":1}],"ver":"1.0"}}]}}`),
+			outSourceExt: nil,
+			outExt:       nil,
+			hasError:     true,
+		},
+	}
+
+	for _, test := range testCases {
+		req := newBidRequest(t)
+		req.Source.Ext = test.inSourceExt
+		req.Ext = test.inExt
+
+		results, _, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, config.Privacy{})
+		result := results["appnexus"]
+
+		if test.hasError == true {
+			assert.NotNil(t, errs)
+			assert.Nil(t, result)
+		} else {
+			assert.Nil(t, errs)
+			assert.Equal(t, test.outSourceExt, result.Source.Ext, test.description+":Source.Ext")
+			assert.Equal(t, test.outExt, result.Ext, test.description+":Ext")
+		}
+	}
+}
+
 func TestCleanOpenRTBRequestsLMT(t *testing.T) {
 	var (
 		enabled  int8 = 1
@@ -302,5 +388,99 @@ func TestRandomizeList(t *testing.T) {
 	if len(adapters) != 1 {
 		t.Errorf("RandomizeList, expected a list of 1, found %d", len(adapters))
 	}
+}
+
+func TestBidderToPrebidChains(t *testing.T) {
+	input := openrtb_ext.ExtRequest{
+		Prebid: openrtb_ext.ExtRequestPrebid{
+			SChains: []*openrtb_ext.ExtRequestPrebidSChain{
+				{
+					Bidders: []string{"Bidder1", "Bidder2"},
+					SChain: openrtb_ext.ExtRequestPrebidSChainSChain{
+						Complete: 1,
+						Nodes: []*openrtb_ext.ExtRequestPrebidSChainSChainNode{
+							{
+								ASI:    "asi1",
+								SID:    "sid1",
+								Name:   "name1",
+								RID:    "rid1",
+								Domain: "domain1",
+								HP:     1,
+							},
+							{
+								ASI:    "asi2",
+								SID:    "sid2",
+								Name:   "name2",
+								RID:    "rid2",
+								Domain: "domain2",
+								HP:     2,
+							},
+						},
+						Ver: "version1",
+					},
+				},
+				{
+					Bidders: []string{"Bidder3", "Bidder4"},
+					SChain:  openrtb_ext.ExtRequestPrebidSChainSChain{},
+				},
+			},
+		},
+	}
+
+	output, err := BidderToPrebidSChains(&input)
+
+	assert.Nil(t, err)
+	assert.Equal(t, len(output), 4)
+	assert.Same(t, output["Bidder1"], &input.Prebid.SChains[0].SChain)
+	assert.Same(t, output["Bidder2"], &input.Prebid.SChains[0].SChain)
+	assert.Same(t, output["Bidder3"], &input.Prebid.SChains[1].SChain)
+	assert.Same(t, output["Bidder4"], &input.Prebid.SChains[1].SChain)
+}
+
+func TestBidderToPrebidChainsDiscardMultipleChainsForBidder(t *testing.T) {
+	input := openrtb_ext.ExtRequest{
+		Prebid: openrtb_ext.ExtRequestPrebid{
+			SChains: []*openrtb_ext.ExtRequestPrebidSChain{
+				{
+					Bidders: []string{"Bidder1"},
+					SChain:  openrtb_ext.ExtRequestPrebidSChainSChain{},
+				},
+				{
+					Bidders: []string{"Bidder1", "Bidder2"},
+					SChain:  openrtb_ext.ExtRequestPrebidSChainSChain{},
+				},
+			},
+		},
+	}
+
+	output, err := BidderToPrebidSChains(&input)
+
+	assert.NotNil(t, err)
+	assert.Nil(t, output)
+}
+
+func TestBidderToPrebidChainsNilSChains(t *testing.T) {
+	input := openrtb_ext.ExtRequest{
+		Prebid: openrtb_ext.ExtRequestPrebid{
+			SChains: nil,
+		},
+	}
+
+	output, err := BidderToPrebidSChains(&input)
+
+	assert.Nil(t, err)
+	assert.Equal(t, len(output), 0)
+}
+
+func TestBidderToPrebidChainsZeroLengthSChains(t *testing.T) {
+	input := openrtb_ext.ExtRequest{
+		Prebid: openrtb_ext.ExtRequestPrebid{
+			SChains: []*openrtb_ext.ExtRequestPrebidSChain{},
+		},
+	}
+
+	output, err := BidderToPrebidSChains(&input)
 
+	assert.Nil(t, err)
+	assert.Equal(t, len(output), 0)
 }