diff --git a/server/remote_signer.go b/server/remote_signer.go index 00b70fc75d..49fc035cb1 100644 --- a/server/remote_signer.go +++ b/server/remote_signer.go @@ -233,7 +233,11 @@ type RemotePaymentRequest struct { // Set if an ID is needed to tie into orch accounting for a session. Optional ManifestID string `json:"manifestId,omitempty"` - // Number of pixels to generate a ticket for. Required if `type` is not set. + // Number of pixels (units) to generate a ticket for. Required if `type` is + // not set. For the `lv2v` type it is optional and, when supplied (> 0), + // takes precedence over the automatic 720p time-based estimate; a + // fixed-price single-shot live-runner generation sets it to 1 so the fee + // equals the orchestrator's per-request fixed price. InPixels int64 `json:"inPixels"` // Job type to automatically calculate payments. Valid values: `lv2v`, `byoc`. Optional. @@ -405,6 +409,16 @@ func (ls *LivepeerServer) GenerateLivePayment(w http.ResponseWriter, r *http.Req byocCapability = priceInfo.Constraint } } + // The orchestrator's live-runner enforces SegData.ManifestID == AuthToken.SessionId + // for fixed live payments; a mismatch is rejected with "mismatched manifest and + // auth token". Bind the live manifest id to the challenge's session id so the + // SegData emitted by genSegCreds carries a matching pair. BYOC keeps its + // capability-name manifest id for shared balance tracking across streams. + if req.Type != RemoteType_BYOC { + if sessionID := oInfo.AuthToken.GetSessionId(); sessionID != "" { + manifestID = sessionID + } + } if manifestID == "" { if hasState { // Required for lv2v so stateful requests stay tied to the same id. @@ -497,13 +511,29 @@ func (ls *LivepeerServer) GenerateLivePayment(w http.ResponseWriter, r *http.Req } billableSecs := now.Sub(lastUpdate).Seconds() if req.Type == RemoteType_LiveVideoToVideo { - info := defaultSegInfo - if billableSecs <= 0 { - // preload with 60 seconds of data for LV2V - billableSecs = (60 * time.Second).Seconds() + if pixels <= 0 { + // No explicit unit count supplied: estimate the billable volume of a + // continuous 720p30 live-video stream for the elapsed window. This is + // the daydream live-video plane and the live-runner "720p" + // (720p-pixel-seconds) unit, which top up payment per ~60s of video. + // + // A fixed-price single-shot live-runner generation is different: the + // orchestrator advertises a per-request price (LiveRunnerPriceInfo + // unit "fixed") and debits exactly one unit for the generation + // (server/ai_http.go reservePaidLiveRunnerSession -> AccountPayment + // units:1, requiring ExpectedPrice.PixelsPerUnit == 1). For that path + // the gateway supplies the unit count directly via inPixels (typically + // 1); we must honor it so the fee equals the fixed per-request price. + // Overriding it with the 720p estimate multiplies the fee by ~1.66e9 + // pixels, which inflates numTickets past the max-100 guard. + info := defaultSegInfo + if billableSecs <= 0 { + // preload with 60 seconds of data for LV2V + billableSecs = (60 * time.Second).Seconds() + } + pixelsPerSec := float64(info.Height) * float64(info.Width) * float64(info.FPS) + pixels = int64(pixelsPerSec * billableSecs) // pixels to charge for } - pixelsPerSec := float64(info.Height) * float64(info.Width) * float64(info.FPS) - pixels = int64(pixelsPerSec * billableSecs) // pixels to charge for } else if req.Type == RemoteType_BYOC { // BYOC uses time-based pricing: price per unit of time (typically seconds) // The pixelsPerUnit in the price info represents the time scaling factor diff --git a/server/remote_signer_test.go b/server/remote_signer_test.go index 8a1dbdceed..dc8cc6b6ee 100644 --- a/server/remote_signer_test.go +++ b/server/remote_signer_test.go @@ -425,6 +425,13 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { require.NoError(err) return state }(), + // Without an AuthToken session id to bind the manifest id to, a + // stateful request that also omits the manifest id must be rejected. + orchInfo: func() *net.OrchestratorInfo { + oInfo := proto.Clone(orchInfo).(*net.OrchestratorInfo) + oInfo.AuthToken = &net.AuthToken{} + return oInfo + }(), omitManifestID: true, wantStatus: http.StatusBadRequest, wantMsg: "missing manifestID", @@ -715,6 +722,211 @@ func TestGenerateLivePayment_LV2V_Succeeds(t *testing.T) { } +// TestGenerateLivePayment_LV2V_ManifestBoundToSessionId proves the signer binds +// the live SegData.ManifestID to the 402 challenge's AuthToken.SessionId. The +// orchestrator's live-runner rejects a fixed live payment whose +// SegData.ManifestID does not equal AuthToken.SessionId ("mismatched manifest +// and auth token"), so the emitted segment credentials must carry +// manifestID == sessionId regardless of whether the request supplies its own +// (mismatched) manifest id or omits it entirely. +func TestGenerateLivePayment_LV2V_ManifestBoundToSessionId(t *testing.T) { + require := require.New(t) + + ethClient := newTestEthClient(t) + node, _ := core.NewLivepeerNode(ethClient, "", nil) + node.Balances = core.NewAddressBalances(1 * time.Minute) + var totalTickets uint32 + sender := newMockSender(mockSenderConfig{ + ev: big.NewRat(35, 1), + createTicketBatchFn: func(args mock.Arguments, batch *pm.TicketBatch) { + size := args.Int(1) + *batch = *defaultTicketBatch() + baseSig := []byte(nil) + if len(batch.SenderParams) > 0 && batch.SenderParams[0] != nil { + baseSig = batch.SenderParams[0].Sig + } + batch.SenderParams = make([]*pm.TicketSenderParams, size) + for i := 0; i < size; i++ { + totalTickets++ + batch.SenderParams[i] = &pm.TicketSenderParams{ + SenderNonce: totalTickets, + Sig: baseSig, + } + } + }, + }) + node.Sender = sender + ls := &LivepeerServer{LivepeerNode: node} + + maxPrice := big.NewRat(200, 1) + autoPrice, err := core.NewAutoConvertedPrice("", maxPrice, nil) + require.NoError(err) + BroadcastCfg.SetMaxPrice(autoPrice) + defer BroadcastCfg.SetMaxPrice(nil) + + // The session id the orchestrator issued in its 402 challenge auth token. + const sessionID = "orch-session-abc123" + oInfo := &net.OrchestratorInfo{ + Address: ethClient.addr.Bytes(), + PriceInfo: &net.PriceInfo{PricePerUnit: 150, PixelsPerUnit: 175}, + TicketParams: &net.TicketParams{ + Recipient: pm.RandAddress().Bytes(), + }, + AuthToken: &net.AuthToken{ + Token: []byte("challenge-token"), + SessionId: sessionID, + Expiration: time.Now().Add(1 * time.Hour).Unix(), + }, + } + orchBlob, err := proto.Marshal(oInfo) + require.NoError(err) + + // Decode the signed segment credentials and return the manifest id the + // orchestrator will see, asserting the auth token was preserved. + segManifestID := func(resp RemotePaymentResponse) string { + require.NotEmpty(resp.SegCreds) + raw, err := base64.StdEncoding.DecodeString(resp.SegCreds) + require.NoError(err) + var segData net.SegData + require.NoError(proto.Unmarshal(raw, &segData)) + require.NotNil(segData.AuthToken) + require.Equal(sessionID, segData.AuthToken.SessionId) + return string(segData.ManifestId) + } + + doPayment := func(reqPayload RemotePaymentRequest) RemotePaymentResponse { + reqBody, err := json.Marshal(reqPayload) + require.NoError(err) + req := httptest.NewRequest(http.MethodPost, "/generate-live-payment", bytes.NewReader(reqBody)) + rr := httptest.NewRecorder() + ls.GenerateLivePayment(rr, req) + require.Equal(http.StatusOK, rr.Code, "unexpected status; body=%s", rr.Body.String()) + var resp RemotePaymentResponse + require.NoError(json.NewDecoder(rr.Body).Decode(&resp)) + return resp + } + + // A client-supplied manifest id different from the session id must be + // overridden so the orchestrator's manifest/auth-token check passes. + respOverride := doPayment(RemotePaymentRequest{ + Orchestrator: orchBlob, + ManifestID: "client-supplied-manifest", + InPixels: 1000, + }) + require.Equal(sessionID, segManifestID(respOverride), + "SegData.ManifestID must equal the challenge AuthToken.SessionId") + + // An omitted manifest id must resolve to the session id, not a fresh random + // manifest id (the previous, rejected behavior). + respEmpty := doPayment(RemotePaymentRequest{ + Orchestrator: orchBlob, + InPixels: 1000, + }) + require.Equal(sessionID, segManifestID(respEmpty), + "an omitted manifest id must bind to the session id, not a random id") +} + +// TestGenerateLivePayment_LV2V_FixedPriceHonorsInPixels proves that a +// fixed-price single-shot live-runner generation is charged for exactly one +// unit. The v0.9.0 live-runner orchestrator advertises a per-request price with +// PixelsPerUnit == 1 and debits units:1 for the generation (server/ai_http.go +// reservePaidLiveRunnerSession). The gateway signals this by sending inPixels:1 +// with type "lv2v". Without honoring inPixels the signer falls back to the +// continuous 720p estimate (720*1280*30*60 = 1,658,880,000 pixels), which +// multiplies the fixed per-request price and makes numTickets explode past the +// max-100 guard (observed in e2e as HTTP 400 "numTickets 2721947758 exceeds +// maximum of 100"). +func TestGenerateLivePayment_LV2V_FixedPriceHonorsInPixels(t *testing.T) { + require := require.New(t) + + // Representative flux-schnell per-cap fixed price advertised by the v0.9.0 + // live-runner orchestrator: $0.00315 -> 1648852084881 wei, PixelsPerUnit 1. + const fixedPricePerUnit int64 = 1648852084881 + + ethClient := newTestEthClient(t) + node, _ := core.NewLivepeerNode(ethClient, "", nil) + node.Balances = core.NewAddressBalances(1 * time.Minute) + sender := newMockSender(mockSenderConfig{ + // Ticket EV equals the fixed per-request price so a single winning + // ticket covers exactly one unit. + ev: new(big.Rat).SetInt64(fixedPricePerUnit), + createTicketBatchFn: func(args mock.Arguments, batch *pm.TicketBatch) { + size := args.Int(1) + *batch = *defaultTicketBatch() + baseSig := []byte(nil) + if len(batch.SenderParams) > 0 && batch.SenderParams[0] != nil { + baseSig = batch.SenderParams[0].Sig + } + batch.SenderParams = make([]*pm.TicketSenderParams, size) + for i := 0; i < size; i++ { + batch.SenderParams[i] = &pm.TicketSenderParams{ + SenderNonce: uint32(i + 1), + Sig: baseSig, + } + } + }, + }) + node.Sender = sender + ls := &LivepeerServer{LivepeerNode: node} + + // Global max price must clear the fixed per-request price. + maxPrice, err := core.NewAutoConvertedPrice("", big.NewRat(fixedPricePerUnit*2, 1), nil) + require.NoError(err) + BroadcastCfg.SetMaxPrice(maxPrice) + defer BroadcastCfg.SetMaxPrice(nil) + + oInfo := &net.OrchestratorInfo{ + Address: ethClient.addr.Bytes(), + PriceInfo: &net.PriceInfo{PricePerUnit: fixedPricePerUnit, PixelsPerUnit: 1}, + TicketParams: &net.TicketParams{Recipient: pm.RandAddress().Bytes()}, + AuthToken: &net.AuthToken{ + Token: []byte("challenge-token"), + SessionId: "orch-session-fixed", + Expiration: time.Now().Add(1 * time.Hour).Unix(), + }, + } + orchBlob, err := proto.Marshal(oInfo) + require.NoError(err) + + doPayment := func(reqPayload RemotePaymentRequest) *httptest.ResponseRecorder { + reqBody, err := json.Marshal(reqPayload) + require.NoError(err) + req := httptest.NewRequest(http.MethodPost, "/generate-live-payment", bytes.NewReader(reqBody)) + rr := httptest.NewRecorder() + ls.GenerateLivePayment(rr, req) + return rr + } + + // Fixed single-shot: inPixels:1 must charge exactly one unit -> one ticket. + rr := doPayment(RemotePaymentRequest{ + Orchestrator: orchBlob, + Type: RemoteType_LiveVideoToVideo, + InPixels: 1, + }) + require.Equal(http.StatusOK, rr.Code, "unexpected status; body=%s", rr.Body.String()) + + var resp RemotePaymentResponse + require.NoError(json.NewDecoder(rr.Body).Decode(&resp)) + paymentBytes, err := base64.StdEncoding.DecodeString(resp.Payment) + require.NoError(err) + var payment net.Payment + require.NoError(proto.Unmarshal(paymentBytes, &payment)) + require.Len(payment.TicketSenderParams, 1, + "a fixed-price single-shot generation must mint exactly one ticket") + + // Regression guard: with no inPixels the lv2v path falls back to the + // continuous 720p estimate, which at this fixed per-request price explodes + // numTickets past the max-100 guard. This confirms the fix relies on the + // gateway signalling the fixed unit via inPixels while leaving the + // continuous live-video estimate unchanged. + rrAuto := doPayment(RemotePaymentRequest{ + Orchestrator: orchBlob, + Type: RemoteType_LiveVideoToVideo, + }) + require.Equal(http.StatusBadRequest, rrAuto.Code) + require.Contains(rrAuto.Body.String(), "exceeds maximum of 100") +} + func TestRemoteSigner_Discovery(t *testing.T) { require := require.New(t)