Skip to content

Commit f117b44

Browse files
authored
ai/live: Remote signer implementation for tickets (#3822)
This PR completes the remote signing feature, allowing gateways to retrieve PM tickets for Live AI (live-video-to-video) without requiring any on-chain connectivity or possession of an Ethereum signing key. See the design background for additional motivation and design detail around remote signers. Refer to doc/remote-signer.md for instructions on how to enable this feature. Retrieving tickets is mostly done via implementing the LivePaymentSender interface with a new implementation: `remotePaymentSender` in `live_payment.go`. The LivePaymentSender implementations (signer or non-signer) is also initialized earlier in the process, before an orchestrator is requested, and stored in the LiveParams struct. This is so the gateway can send an upfront payment to the orchestrator using remote signers. Processing remote payment signing requests happens in the `remote_signer.go` file. When a job first starts, the gateway sends an upfront payment to the orchestrator encoded in the initial request header. To support this, the API for the `remotePaymentSender` also offers a standalone `RequestPayment` method to retrieve signed tickets without sending them. The non-remote signer does not have a clean, singular method to retrieve tickets; at some point we may codify this behind a proper interface and clean up this bit, but that can come later to avoid introducing additional concepts to an already involved PR. ### Remote Signing Protocol Refer to the design document for context behind the design of the protocol. Here is some more detail on that: * There are 2 bits of state: the remote signer's state, and the orchestrator's state (OrchestratorInfo ticket parameters). The remote signing protocol is stateless, and each call to sign tickets returns an updated state. The gateway is responsible for retaining both bits of state in between calls, and re-sending the state to the remote signer. * The remote signer's state is itself signed to prevent tampering. The OrchestratorInfo data is already signed. * There is a loose requirement for the gateway to store the payment response since it contains updated OrchestratorInfo data. However, this is not strictly necessary; the existing OrchestratorInfo can be reused until its parameters expire. * If expired OrchestratorInfo parameters are sent to the signer, the signer will respond with an internal status code of 480 ("HTTPStatusRefreshSession") indicating the client should retrieve a fresh set of parameters using an GetOrchestratorInfo RPC request. This comes at the cost of an additional set of requests to the O and the signer, but the impact should be negligible given that Live AI payments are asynchronous and there is typically a bit of a buffer before the gateway depletes its balance with the O. ```mermaid sequenceDiagram participant O as Orchestrator participant G as Gateway participant S as Signer %% Initial session setup G->>S: getOrchInfoSig() S-->>G: gatewaySig G->>O: getOrchInfo(gatewaySig) O-->>G: ticketParams₀ %% First signing call (no prior signer state) Note over S: state is null → create fresh signer state G->>S: signTicket(state=null, ticketParams₀) S-->>G: signedTicket₀, signerState₀ G->>O: pay(signedTicket₀) O-->>G: ticketParams₁ G->>S: signTicket(signerState₀, ticketParams₁) S-->>G: signedTicket₁, signerState₁ %% Subsequent calls (k = 1..N) loop For each k = 1..N Note over S: NB: ticketParamsₖ reusable between<br>calls as long as it is valid but not<br>signedTicketₖ or signerStateₖ G->>S: signTicket(signerStateₖ₋₁, ticketParamsₖ) S-->>G: signedTicketₖ, signerStateₖ G->>O: pay(signedTicketₖ) O-->>G: ticketParamsₖ₊₁ end ``` ### PM Changes All the changes here are used only by the remote signer, so the impact on the existing code is minimal. The `Sender` interface adds two new methods: a StartSessionWithNonce constructor, and a `Nonce` accessor. The nonce is a (mostly internal) PM construct that allows for multiple tickets to be generated using the same set of PM parameters. For ordinary signers, the `Sender` persists for the duration of the session, so the nonce would stay internal and be incremented as necessary. However, since remote signers are stateless, the nonce needs to be extracted and set with each signing call, and that is what we do here. The `Balance` struct has a new `Reserve()` method added to zero out the current balance. This addition makes the `Balance` more closely mirror the API of the nested `AddressBalances()` list. (Otherwise I would have chosen a better name than "Reserve" to zero out a balance.) Note that the `Balances` object itself hides quite a bit of nested global accouting that we don't strictly need here, and it would be much neater to not have to use these in favor of strictly request-local accounting. However, this would make the rest of the implementation more complex, since the `BroadcastSession` works on the global `Balances` and most of the payment helper functions themselves take a `BroadcastSession` ... so here we go. There is also a small change in starter.go to initialize more PM and Ethereum scaffolding (watchers etc) when the node starts up in remote signer mode.
1 parent 778c2e1 commit f117b44

13 files changed

Lines changed: 1744 additions & 33 deletions

cmd/livepeer/starter/starter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1092,7 +1092,7 @@ func StartLivepeer(ctx context.Context, cfg LivepeerConfig) {
10921092
}
10931093

10941094
}
1095-
if n.NodeType == core.BroadcasterNode {
1095+
if n.NodeType == core.BroadcasterNode || n.NodeType == core.RemoteSignerNode {
10961096
maxEV, _ := new(big.Rat).SetString(*cfg.MaxTicketEV)
10971097
if maxEV == nil {
10981098
panic(fmt.Errorf("-maxTicketEV must be a valid rational number, but %v provided. Restart the node with a valid value for -maxTicketEV", *cfg.MaxTicketEV))

core/accounting.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ func (b *Balance) Credit(amount *big.Rat) {
3131
b.balances.Credit(b.addr, b.manifestID, amount)
3232
}
3333

34+
// Reserve zeroes the balance and returns the current balance
35+
func (b *Balance) Reserve() *big.Rat {
36+
return b.balances.Reserve(b.addr, b.manifestID)
37+
}
38+
3439
// StageUpdate prepares a balance update by reserving the current balance and returning the number of tickets
3540
// to send with a payment, the new credit represented by the payment and the existing credit (i.e reserved balance)
3641
func (b *Balance) StageUpdate(minCredit, ev *big.Rat) (int, *big.Rat, *big.Rat) {

doc/remote-signer.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ Example:
8989
-v 6
9090
```
9191

92+
### Pricing checks (gateway vs remote signer)
93+
94+
When running a gateway in offchain mode (ie, with `-remoteSignerUrl` and no Ethereum flags), the gateway does not check orchestrator pricing. Instead, price checks happen in the remote signer during payment generation.
95+
96+
- **Remote signer configuration**: configure the signer with the same pricing and PM knobs you would normally configure on a gateway, e.g.:
97+
- `-maxPricePerUnit`, `-pixelsPerUnit`
98+
- `-maxPricePerCapability` (optional, capability/model pricing config)
99+
- `-maxTicketEV`, `-maxTotalEV`, etc.
100+
- **Selection behavior**: if an orchestrator’s price is above the signer’s configured limits, the signer rejects the request (HTTP 481) and the gateway will retry with a different orchestrator session.
101+
- **LV2V session price is fixed**: like a traditional gateway setup, Live Video-to-Video (LV2V) jobs treat price as fixed for the lifetime of the session, captured at session initialization time.
102+
103+
### Tuning ticket EV to avoid “too many tickets” errors
104+
105+
If there are errors about too many tickets (eg `numTickets ... exceeds maximum of 100`), increase the ticket EV on the remote signer so each signing call produces fewer tickets. A good target is ~1–3 tickets per remote signer call.
106+
107+
For PM configuration details and how these knobs interact, see `doc/payments.md`.
108+
92109
## Operational + security guidance
93110

94111
For the moment, remote signers are intended to sit behind infrastructure controls rather than being exposed directly to end-users. For example, run the remote signer on a private network or behind an authenticated proxy. Do not expose the remote signer to unauthenticated end-users. Run the remote signer close to gateways on a private network; protect it like you would an internal wallet service.

pm/sender.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ type Sender interface {
2222
// for creating new tickets
2323
StartSession(ticketParams TicketParams) string
2424

25+
// StartSessionWithNonce is like StartSession with a non-default nonce
26+
StartSessionWithNonce(ticketParams TicketParams, nonce uint32) string
27+
2528
// CleanupSession deletes session from the internal map
2629
CleanupSession(sessionID string)
2730

@@ -33,6 +36,9 @@ type Sender interface {
3336

3437
// EV returns the ticket EV for a session
3538
EV(sessionID string) (*big.Rat, error)
39+
40+
// Nonce returns the current nonce for a session
41+
Nonce(sessionID string) (uint32, error)
3642
}
3743

3844
type session struct {
@@ -75,6 +81,17 @@ func (s *sender) StartSession(ticketParams TicketParams) string {
7581
return sessionID
7682
}
7783

84+
func (s *sender) StartSessionWithNonce(ticketParams TicketParams, nonce uint32) string {
85+
sessionID := ticketParams.RecipientRandHash.Hex()
86+
87+
s.sessions.Store(sessionID, &session{
88+
ticketParams: ticketParams,
89+
senderNonce: nonce,
90+
})
91+
92+
return sessionID
93+
}
94+
7895
// EV returns the ticket EV for a session
7996
func (s *sender) EV(sessionID string) (*big.Rat, error) {
8097
session, err := s.loadSession(sessionID)
@@ -85,6 +102,14 @@ func (s *sender) EV(sessionID string) (*big.Rat, error) {
85102
return ticketEV(session.ticketParams.FaceValue, session.ticketParams.WinProb), nil
86103
}
87104

105+
func (s *sender) Nonce(sessionID string) (uint32, error) {
106+
session, err := s.loadSession(sessionID)
107+
if err != nil {
108+
return 0, err
109+
}
110+
return session.senderNonce, nil
111+
}
112+
88113
func (s *sender) CleanupSession(sessionID string) {
89114
s.sessions.Delete(sessionID)
90115
}

pm/stub.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,11 @@ func (m *MockSender) StartSession(ticketParams TicketParams) string {
511511
return args.String(0)
512512
}
513513

514+
func (m *MockSender) StartSessionWithNonce(ticketParams TicketParams, nonce uint32) string {
515+
args := m.Called(ticketParams, nonce)
516+
return args.String(0)
517+
}
518+
514519
// CleanupSession deletes session from the internal ma
515520
func (m *MockSender) CleanupSession(sessionID string) {
516521
m.Called(sessionID)
@@ -545,3 +550,8 @@ func (m *MockSender) ValidateTicketParams(ticketParams *TicketParams) error {
545550
args := m.Called(ticketParams)
546551
return args.Error(0)
547552
}
553+
554+
func (m *MockSender) Nonce(sessionID string) (uint32, error) {
555+
args := m.Called(sessionID)
556+
return uint32(args.Int(0)), args.Error(1)
557+
}

server/ai_live_video.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func startTricklePublish(ctx context.Context, url *url.URL, params aiRequestPara
8383
priceInfo := sess.OrchestratorInfo.PriceInfo
8484
var paymentProcessor *LivePaymentProcessor
8585
if priceInfo != nil && priceInfo.PricePerUnit != 0 {
86-
paymentSender := livePaymentSender{}
86+
paymentSender := params.liveParams.paymentSender
8787
sendPaymentFunc := func(inPixels int64) error {
8888
return paymentSender.SendPayment(context.Background(), &SegmentInfoSender{
8989
sess: sess.BroadcastSession,

server/ai_mediaserver.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ func processStream(ctx context.Context, params aiRequestParams, req worker.GenLi
695695
var err error
696696
for {
697697
perOrchCtx, perOrchCancel := context.WithCancelCause(ctx)
698-
params.liveParams = newParams(params.liveParams, perOrchCancel)
698+
params.liveParams = newLiveParams(params, perOrchCancel)
699699
var resp interface{}
700700
resp, err = processAIRequest(perOrchCtx, params, req)
701701
if err != nil {
@@ -761,7 +761,8 @@ func processStream(ctx context.Context, params aiRequestParams, req worker.GenLi
761761
<-firstProcessed
762762
}
763763

764-
func newParams(params *liveRequestParams, cancelOrch context.CancelCauseFunc) *liveRequestParams {
764+
func newLiveParams(aiParams aiRequestParams, cancelOrch context.CancelCauseFunc) *liveRequestParams {
765+
params := aiParams.liveParams
765766
return &liveRequestParams{
766767
segmentReader: params.segmentReader,
767768
rtmpOutputs: params.rtmpOutputs,
@@ -778,8 +779,15 @@ func newParams(params *liveRequestParams, cancelOrch context.CancelCauseFunc) *l
778779
orchestrator: params.orchestrator,
779780
startTime: time.Now(),
780781
kickOrch: cancelOrch,
782+
paymentSender: choosePaymentSender(aiParams),
781783
}
784+
}
782785

786+
func choosePaymentSender(params aiRequestParams) LivePaymentSender {
787+
if hasRemoteSigner(params) {
788+
return NewRemotePaymentSender(params.node)
789+
}
790+
return &livePaymentSender{}
783791
}
784792

785793
func startProcessing(ctx context.Context, params aiRequestParams, res interface{}) error {

server/ai_process.go

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type liveRequestParams struct {
104104
pipeline string
105105
orchestrator string
106106

107+
paymentSender LivePaymentSender
107108
paymentProcessInterval time.Duration
108109
outSegmentTimeout time.Duration
109110

@@ -1051,28 +1052,54 @@ func submitLiveVideoToVideo(ctx context.Context, params aiRequestParams, sess *A
10511052
params.liveParams.sess = sess
10521053
params.liveParams.startTime = time.Now()
10531054

1054-
// Live Video should not reuse the existing session balance, because it could lead to not sending the init
1055-
// payment, which in turns may cause "Insufficient Balance" on the Orchestrator's side.
1056-
// It works differently than other AI Jobs, because Live Video is accounted by mid on the Orchestrator's side.
1057-
clearSessionBalance(sess.BroadcastSession, core.RandomManifestID())
1055+
var paymentHeaders worker.RequestEditorFn
1056+
if hasRemoteSigner(params) {
1057+
rpp, ok := params.liveParams.paymentSender.(*remotePaymentSender)
1058+
if !ok {
1059+
return nil, errors.New("remote sender was not the correct type")
1060+
}
1061+
res, err := rpp.RequestPayment(ctx, &SegmentInfoSender{
1062+
sess: sess.BroadcastSession,
1063+
})
1064+
if err != nil {
1065+
return nil, err
1066+
}
1067+
paymentHeaders = func(_ context.Context, req *http.Request) error {
1068+
req.Header.Set(segmentHeader, res.SegCreds)
1069+
req.Header.Set(paymentHeader, res.Payment)
1070+
req.Header.Set("Authorization", protoVerAIWorker)
1071+
return nil
1072+
}
1073+
} else {
10581074

1059-
client, err := worker.NewClientWithResponses(sess.Transcoder(), worker.WithHTTPClient(httpClient))
1060-
if err != nil {
1061-
if monitor.Enabled {
1062-
monitor.AIRequestError(err.Error(), "LiveVideoToVideo", *req.ModelId, sess.OrchestratorInfo)
1075+
// Live Video should not reuse the existing session balance, because it could lead to not sending the init
1076+
// payment, which in turns may cause "Insufficient Balance" on the Orchestrator's side.
1077+
// It works differently than other AI Jobs, because Live Video is accounted by mid on the Orchestrator's side.
1078+
clearSessionBalance(sess.BroadcastSession, core.RandomManifestID())
1079+
1080+
var (
1081+
balUpdate *BalanceUpdate
1082+
err error
1083+
)
1084+
paymentHeaders, balUpdate, err = prepareAIPayment(ctx, sess, initPixelsToPay)
1085+
if err != nil {
1086+
if monitor.Enabled {
1087+
monitor.AIRequestError(err.Error(), "LiveVideoToVideo", *req.ModelId, sess.OrchestratorInfo)
1088+
}
1089+
return nil, err
10631090
}
1064-
return nil, err
1091+
defer completeBalanceUpdate(sess.BroadcastSession, balUpdate)
10651092
}
1066-
paymentHeaders, balUpdate, err := prepareAIPayment(ctx, sess, initPixelsToPay)
1093+
1094+
// Send request to orchestrator
1095+
client, err := worker.NewClientWithResponses(sess.Transcoder(), worker.WithHTTPClient(httpClient))
10671096
if err != nil {
10681097
if monitor.Enabled {
10691098
monitor.AIRequestError(err.Error(), "LiveVideoToVideo", *req.ModelId, sess.OrchestratorInfo)
10701099
}
10711100
return nil, err
10721101
}
1073-
defer completeBalanceUpdate(sess.BroadcastSession, balUpdate)
10741102

1075-
// Send request to orchestrator
10761103
reqTimeout := 5 * time.Second
10771104
reqCtx, cancel := context.WithTimeout(ctx, reqTimeout)
10781105
defer cancel()
@@ -1669,3 +1696,7 @@ func encodeReqMetadata(metadata map[string]string) string {
16691696
metadataBytes, _ := json.Marshal(metadata)
16701697
return string(metadataBytes)
16711698
}
1699+
1700+
func hasRemoteSigner(params aiRequestParams) bool {
1701+
return params.node != nil && params.node.RemoteSignerUrl != nil
1702+
}

server/handlers_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@ func TestSetBroadcastConfigHandler_TranscodingOptionsError(t *testing.T) {
243243
func TestSetBroadcastConfigHandler_Success(t *testing.T) {
244244
assert := assert.New(t)
245245

246+
oldProfs := BroadcastJobVideoProfiles
247+
defer func() { BroadcastJobVideoProfiles = oldProfs }()
248+
defer BroadcastCfg.SetMaxPrice(nil)
249+
246250
handler := setBroadcastConfigHandler()
247251
status, _ := postForm(handler, url.Values{
248252
"maxPricePerUnit": {"1"},
@@ -269,6 +273,7 @@ func TestSetMaxPriceForCapabilityHandler(t *testing.T) {
269273
//set default max price
270274
basePrice, _ := core.NewAutoConvertedPrice("WEI", big.NewRat(10, 1), nil)
271275
BroadcastCfg.SetMaxPrice(basePrice)
276+
defer BroadcastCfg.SetMaxPrice(nil)
272277

273278
//set price per unit for specific pipeline
274279
p1, _ := core.NewAutoConvertedPrice("WEI", big.NewRat(1, 1), nil)
@@ -281,6 +286,10 @@ func TestSetMaxPriceForCapabilityHandler(t *testing.T) {
281286
p2_pipeline_cap, _ := core.PipelineToCapability(p2_pipeline)
282287
p2_modelID := "default"
283288

289+
defer BroadcastCfg.SetCapabilityMaxPrice(p1_pipeline_cap, "default", nil)
290+
defer BroadcastCfg.SetCapabilityMaxPrice(p2_pipeline_cap, "default", nil)
291+
defer BroadcastCfg.SetCapabilityMaxPrice(p1_pipeline_cap, "stabilityai/sd-turbo", nil)
292+
284293
status1, _ := postForm(handler, url.Values{
285294
"maxPricePerUnit": {"1"},
286295
"pixelsPerUnit": {"1"},
@@ -450,6 +459,10 @@ func TestGetNetworkCapabilitiesHandler(t *testing.T) {
450459
func TestGetBroadcastConfigHandler(t *testing.T) {
451460
assert := assert.New(t)
452461

462+
oldProfs := BroadcastJobVideoProfiles
463+
defer func() { BroadcastJobVideoProfiles = oldProfs }()
464+
defer BroadcastCfg.SetMaxPrice(nil)
465+
453466
BroadcastCfg.SetMaxPrice(core.NewFixedPrice(big.NewRat(1, 2)))
454467
BroadcastJobVideoProfiles = []ffmpeg.VideoProfile{
455468
ffmpeg.VideoProfileLookup["P240p25fps16x9"],

0 commit comments

Comments
 (0)