Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions server/remote_signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
212 changes: 212 additions & 0 deletions server/remote_signer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down
Loading