From feb01a9fa340e05e428a2ddc55d3f4e053aaf4a4 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 9 Feb 2026 21:19:39 -0800 Subject: [PATCH 1/5] ai/live: Special-case zero NumTickets Per the code: if the balance is already large enough to cover the required minimum credit (fee with ticket EV as the floor) then the code currently does not generate a ticket. Checking for this case prevents us from creating an empty-ticket payment. While those are not a huge problem for the orchestrators, it still leads to undesirable noise across the network. --- server/remote_signer.go | 10 ++++++++++ server/remote_signer_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/server/remote_signer.go b/server/remote_signer.go index 8dbdffae15..02727e2a78 100644 --- a/server/remote_signer.go +++ b/server/remote_signer.go @@ -26,6 +26,7 @@ import ( const HTTPStatusRefreshSession = 480 const HTTPStatusPriceExceeded = 481 +const HTTPStatusNoTickets = 482 const RemoteType_LiveVideoToVideo = "lv2v" // SignOrchestratorInfo handles signing GetOrchestratorInfo requests for multiple orchestrators @@ -393,6 +394,15 @@ func (ls *LivepeerServer) GenerateLivePayment(w http.ResponseWriter, r *http.Req respondJsonError(ctx, w, err, http.StatusInternalServerError) return } + if balUpdate.NumTickets <= 0 { + // No new tickets are needed when reserved balance already covers the + // required minimum credit (fee with ticket EV as the floor). Caller + // should retry once balance has been run down further. + err = errors.New("no tickets") + clog.Errorf(ctx, "No tickets") + respondJsonError(ctx, w, err, HTTPStatusNoTickets) + return + } if balUpdate.NumTickets > 100 { // Prevent both draining funds and perf issues ev, err := sender.EV(sess.PMSessionID) diff --git a/server/remote_signer_test.go b/server/remote_signer_test.go index 759950768d..d031f0987a 100644 --- a/server/remote_signer_test.go +++ b/server/remote_signer_test.go @@ -420,6 +420,34 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { wantStatus: HTTPStatusPriceExceeded, wantMsg: "Orchestrator price has more than doubled", }, + { + name: "zero tickets returns 482", + stateBytes: func() []byte { + stateBytes, err := json.Marshal(RemotePaymentState{ + StateID: "state", + OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), + // Existing balance large enough so StageUpdate yields NumTickets == 0. + Balance: "1000", + InitialPricePerUnit: 1, + InitialPixelsPerUnit: 1, + }) + require.NoError(err) + return stateBytes + }(), + stateSig: func() []byte { + stateBytes, err := json.Marshal(RemotePaymentState{ + StateID: "state", + OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), + Balance: "1000", + InitialPricePerUnit: 1, + InitialPixelsPerUnit: 1, + }) + require.NoError(err) + return sign(stateBytes) + }(), + wantStatus: HTTPStatusNoTickets, + wantMsg: "no tickets", + }, } for _, tt := range tests { From 371141ae3cb0cf2871d603496149030a8061fffa Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 9 Feb 2026 21:39:52 -0800 Subject: [PATCH 2/5] ai/live: Check for a required ManifestID Help out SDK implementers in case they miss this, since the errors are non-obvious: the orchestrator runs out of balance within a few minutes, even if payments are being sent regularly. --- server/remote_signer.go | 9 +++++++- server/remote_signer_test.go | 45 +++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/server/remote_signer.go b/server/remote_signer.go index 02727e2a78..a45928a97e 100644 --- a/server/remote_signer.go +++ b/server/remote_signer.go @@ -249,7 +249,8 @@ func (ls *LivepeerServer) GenerateLivePayment(w http.ResponseWriter, r *http.Req err error ) reqState, reqSig := req.State.State, req.State.Sig - if len(reqState) != 0 || len(reqSig) != 0 { + hasState := len(reqState) != 0 || len(reqSig) != 0 + if hasState { if err := verifyStateSignature(ls, reqState, reqSig); err != nil { err = errors.New("invalid sig") respondJsonError(ctx, w, err, http.StatusBadRequest) @@ -279,6 +280,12 @@ func (ls *LivepeerServer) GenerateLivePayment(w http.ResponseWriter, r *http.Req manifestID := req.ManifestID if manifestID == "" { + if hasState { + // Required for lv2v so stateful requests stay tied to the same id. + err := errors.New("missing manifestID") + respondJsonError(ctx, w, err, http.StatusBadRequest) + return + } manifestID = string(core.RandomManifestID()) } ctx = clog.AddVal(ctx, "manifest_id", manifestID) diff --git a/server/remote_signer_test.go b/server/remote_signer_test.go index d031f0987a..a969019f66 100644 --- a/server/remote_signer_test.go +++ b/server/remote_signer_test.go @@ -359,13 +359,40 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { }() tests := []struct { - name string - stateBytes []byte - stateSig []byte - orchInfo *net.OrchestratorInfo - wantStatus int - wantMsg string + name string + stateBytes []byte + stateSig []byte + orchInfo *net.OrchestratorInfo + omitManifestID bool + wantStatus int + wantMsg string }{ + { + name: "missing manifest id with state", + stateBytes: func() []byte { + state, err := json.Marshal(RemotePaymentState{ + StateID: "state", + OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), + InitialPricePerUnit: 1, + InitialPixelsPerUnit: 1, + }) + require.NoError(err) + return state + }(), + stateSig: func() []byte { + state, err := json.Marshal(RemotePaymentState{ + StateID: "state", + OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), + InitialPricePerUnit: 1, + InitialPixelsPerUnit: 1, + }) + require.NoError(err) + return sign(state) + }(), + omitManifestID: true, + wantStatus: http.StatusBadRequest, + wantMsg: "missing manifestID", + }, { name: "invalid state signature", stateBytes: []byte(`{"stateID":"state","orchestratorAddress":"0x1"}`), @@ -459,8 +486,14 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { orchBlob, err := proto.Marshal(oInfo) require.NoError(err) + var manifestID string + if !tt.omitManifestID { + manifestID = "manifest" + } + reqBody, err := json.Marshal(RemotePaymentRequest{ Orchestrator: orchBlob, + ManifestID: manifestID, InPixels: 1, State: RemotePaymentStateSig{State: tt.stateBytes, Sig: tt.stateSig}, }) From 763640e14156394bfcd3c969c96210aa1dffd33e Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Mon, 9 Feb 2026 21:50:43 -0800 Subject: [PATCH 3/5] ai/live: Simplify state in remote signer unit tests. Compute the signature for test cases automatically if one was not provided as part of the test. Saves a bunch of boilerplate. --- server/remote_signer_test.go | 42 ++++++------------------------------ 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/server/remote_signer_test.go b/server/remote_signer_test.go index a969019f66..fc4920b1ef 100644 --- a/server/remote_signer_test.go +++ b/server/remote_signer_test.go @@ -347,7 +347,7 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { return sig } - priceIncreaseStateBytes, priceIncreaseStateSig := func() ([]byte, []byte) { + priceIncreaseStateBytes := func() []byte { stateBytes, err := json.Marshal(RemotePaymentState{ StateID: "state", OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), @@ -355,7 +355,7 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { InitialPixelsPerUnit: 1, }) require.NoError(err) - return stateBytes, sign(stateBytes) + return stateBytes }() tests := []struct { @@ -379,16 +379,6 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { require.NoError(err) return state }(), - stateSig: func() []byte { - state, err := json.Marshal(RemotePaymentState{ - StateID: "state", - OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), - InitialPricePerUnit: 1, - InitialPixelsPerUnit: 1, - }) - require.NoError(err) - return sign(state) - }(), omitManifestID: true, wantStatus: http.StatusBadRequest, wantMsg: "missing manifestID", @@ -403,7 +393,6 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { { name: "invalid state json", stateBytes: []byte("not-json"), - stateSig: sign([]byte("not-json")), wantStatus: http.StatusBadRequest, wantMsg: "invalid state", }, @@ -418,15 +407,6 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { require.NoError(err) return state }(), - stateSig: sign(func() []byte { - state, err := json.Marshal(RemotePaymentState{ - StateID: "state", - OrchestratorAddress: ethcommon.HexToAddress("0x1"), - InitialPixelsPerUnit: 1, - }) - require.NoError(err) - return state - }()), orchInfo: func() *net.OrchestratorInfo { oInfo := proto.Clone(orchInfo).(*net.OrchestratorInfo) oInfo.Address = ethcommon.HexToAddress("0x2").Bytes() @@ -438,7 +418,6 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { { name: "orchestrator price increased more than 2x", stateBytes: priceIncreaseStateBytes, - stateSig: priceIncreaseStateSig, orchInfo: func() *net.OrchestratorInfo { oInfo := proto.Clone(orchInfo).(*net.OrchestratorInfo) oInfo.PriceInfo = &net.PriceInfo{PricePerUnit: 250, PixelsPerUnit: 1} @@ -461,17 +440,6 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { require.NoError(err) return stateBytes }(), - stateSig: func() []byte { - stateBytes, err := json.Marshal(RemotePaymentState{ - StateID: "state", - OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), - Balance: "1000", - InitialPricePerUnit: 1, - InitialPixelsPerUnit: 1, - }) - require.NoError(err) - return sign(stateBytes) - }(), wantStatus: HTTPStatusNoTickets, wantMsg: "no tickets", }, @@ -490,12 +458,16 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { if !tt.omitManifestID { manifestID = "manifest" } + stateSig := tt.stateSig + if stateSig == nil { + stateSig = sign(tt.stateBytes) + } reqBody, err := json.Marshal(RemotePaymentRequest{ Orchestrator: orchBlob, ManifestID: manifestID, InPixels: 1, - State: RemotePaymentStateSig{State: tt.stateBytes, Sig: tt.stateSig}, + State: RemotePaymentStateSig{State: tt.stateBytes, Sig: stateSig}, }) require.NoError(err) From 0a857b7812627bbc00d9f01f4113fc309fb7a3e2 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 10 Feb 2026 10:42:44 -0800 Subject: [PATCH 4/5] go fmt --- server/remote_signer_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/remote_signer_test.go b/server/remote_signer_test.go index fc4920b1ef..54f1d59482 100644 --- a/server/remote_signer_test.go +++ b/server/remote_signer_test.go @@ -430,8 +430,8 @@ func TestGenerateLivePayment_StateValidationErrors(t *testing.T) { name: "zero tickets returns 482", stateBytes: func() []byte { stateBytes, err := json.Marshal(RemotePaymentState{ - StateID: "state", - OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), + StateID: "state", + OrchestratorAddress: ethcommon.BytesToAddress(orchInfo.Address), // Existing balance large enough so StageUpdate yields NumTickets == 0. Balance: "1000", InitialPricePerUnit: 1, From e1fa6b2a30831954c29778a5e79093353784b5e3 Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 10 Feb 2026 14:02:59 -0800 Subject: [PATCH 5/5] fix tests --- server/remote_signer_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/remote_signer_test.go b/server/remote_signer_test.go index 54f1d59482..dd25a0c3b9 100644 --- a/server/remote_signer_test.go +++ b/server/remote_signer_test.go @@ -566,9 +566,11 @@ func TestGenerateLivePayment_LV2V_Succeeds(t *testing.T) { } orchBlob, err := proto.Marshal(oInfo) require.NoError(err) + const manifestID = "lv2v-manifest" resp, payment := doPayment(RemotePaymentRequest{ Orchestrator: orchBlob, + ManifestID: manifestID, InPixels: inPixels, }) require.NotEmpty(resp.Payment) @@ -612,6 +614,7 @@ func TestGenerateLivePayment_LV2V_Succeeds(t *testing.T) { const inPixelsUpdated int64 = 2500 resp2, payment2 := doPayment(RemotePaymentRequest{ Orchestrator: orchBlob, + ManifestID: manifestID, InPixels: inPixelsUpdated, State: resp.State, })