From 0c5dfecd787711dea764ed332a4ffc5f5275cbd6 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 5 Aug 2026 13:48:08 +0200 Subject: [PATCH 1/2] eth,server,cmd: Add LIP-118 reward caller support LIP-118 lets an orchestrator authorize a separate, low-privilege address to call reward on its behalf, so the stake-holding wallet can stay cold. The contract side shipped in livepeer/protocol#648 and is live on Arbitrum One, but go-livepeer had no support for it. Add SetRewardCaller/GetRewardCaller/RewardForTranscoder to LivepeerEthClient and regenerate the BondingManager bindings. Reward() is refactored into a shared rewardFor(transcoder, delegated) so the transcoder pool hint math is identical on both paths and always keyed on the transcoder rather than the caller. The reward service now takes the orchestrator address, reads eligibility from the orchestrator's record (a reward caller is not itself a transcoder, so its record is empty and inactive), and re-checks authorization every round so a revoked caller reports a clear error instead of a bare on-chain revert. Authorization is validated at startup, but only when reward is actually going to run: -ethOrchAddr also designates ticket recipients for gateways, redeemers and orchestrators that never call reward, and those must not pay for an extra lookup or inherit a new failure mode. An explicit -reward with an unauthorized wallet exits; an auto-enabled one warns and stays off. Registration status is deliberately not a hard failure, since an orchestrator may still be registering via livepeer_cli against the node itself. The service URI lookup also moves to the orchestrator address. It is registered against the transcoder, so a node on a caller wallet previously resolved an empty URI and exited at startup. Guard the orchestrator config actions against delegated wallets. The contracts key these on msg.sender: reward cut / fee share reverts via isRegisteredTranscoder, but ServiceRegistry.setServiceURI has no caller check at all and would silently succeed against the wrong key. setOrchestratorConfig compounded this by diffing against an empty record and firing both transactions. orchestratorInfoHandler now reports the orchestrator's record so livepeer_cli shows real status, stake and LastRewardRound on a caller wallet. Add a "Set reward caller" livepeer_cli action and a /setRewardCaller endpoint, where an empty value unsets via the zero address. This signs with the node's wallet, so it is a pre-migration step run while the node still holds the orchestrator key. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/livepeer/starter/starter.go | 82 +++++++-- cmd/livepeer/starter/starter_test.go | 10 +- cmd/livepeer_cli/livepeer_cli.go | 1 + cmd/livepeer_cli/wizard_transcoder.go | 61 +++++++ eth/client.go | 41 ++++- eth/contracts/bondingManager.go | 249 +++++++++++++++++++++++++- eth/rewardservice.go | 75 ++++++-- eth/rewardservice_test.go | 152 +++++++++++++++- eth/stubclient.go | 45 ++++- server/handlers.go | 127 ++++++++++++- server/handlers_test.go | 133 +++++++++++++- server/webserver.go | 4 +- 12 files changed, 933 insertions(+), 47 deletions(-) diff --git a/cmd/livepeer/starter/starter.go b/cmd/livepeer/starter/starter.go index a9a8e32838..da373d729b 100755 --- a/cmd/livepeer/starter/starter.go +++ b/cmd/livepeer/starter/starter.go @@ -1262,27 +1262,64 @@ func StartLivepeer(ctx context.Context, cfg LivepeerConfig) { glog.Infof("Redeemer started on %v", *cfg.HttpAddr) } + // Reward is called for recipientAddr, which is the node's own account unless + // -ethOrchAddr points at a separately registered orchestrator. Authorization is + // only ever checked when reward is actually going to run: -ethOrchAddr is also + // used for ticket recipients by gateways, redeemers and orchestrators that never + // call reward, and those must not pay for an extra lookup or inherit a new + // failure mode. var reward bool - if cfg.Reward == nil { - // If the node address is an on-chain registered address, start the reward service - t, err := n.Eth.GetTranscoder(n.Eth.Account().Address) - if err != nil { - glog.Error(err) - return + if cfg.Reward == nil || *cfg.Reward { + explicit := cfg.Reward != nil + + // A node running on the orchestrator's own wallet calls reward directly. + // Otherwise it must be the reward caller the orchestrator authorized + // on-chain, or every reward transaction would revert. Check up front so the + // operator finds out at startup rather than a round later. + authorized := true + if recipientAddr != n.Eth.Account().Address { + rewardCaller, err := n.Eth.GetRewardCaller(recipientAddr) + if err != nil { + glog.Errorf("Could not look up reward caller for orchestrator %v err=%q", recipientAddr.Hex(), err) + return + } + authorized = rewardCaller == n.Eth.Account().Address + if !authorized { + msg := fmt.Sprintf( + "node account %v is not the reward caller for orchestrator %v (on-chain reward caller is %v); "+ + "set it with livepeer_cli from the orchestrator's wallet", + n.Eth.Account().Address.Hex(), recipientAddr.Hex(), rewardCaller.Hex(), + ) + if explicit { + exit("-reward was set but %s", msg) + } + glog.Warningf("Not starting reward service; %s", msg) + } } - if t.Status == "Registered" { - reward = true - } else { + + switch { + case !authorized: reward = false + case explicit: + // Deliberately not gated on registration status. The orchestrator may + // still be registering, possibly via livepeer_cli against this very + // node, and the reward service is a no-op until it is active. + reward = true + default: + // Auto-enable only when there is a registered orchestrator to reward. + t, err := n.Eth.GetTranscoder(recipientAddr) + if err != nil { + glog.Error(err) + return + } + reward = t.Status == "Registered" } - } else { - reward = *cfg.Reward } if reward { // Start reward service // The node will only call reward if it is active in the current round - rs := eth.NewRewardService(n.Eth, timeWatcher) + rs := eth.NewRewardService(n.Eth, timeWatcher, recipientAddr) go func() { if err := rs.Start(ctx); err != nil { serviceErr <- err @@ -1792,7 +1829,12 @@ func StartLivepeer(ctx context.Context, cfg LivepeerConfig) { } else if n.NodeType == core.OrchestratorNode { *cfg.CliAddr = defaultAddr(*cfg.CliAddr, "127.0.0.1", OrchestratorCliPort) - suri, err := getServiceURI(n, *cfg.ServiceAddr) + var ethOrchAddr ethcommon.Address + if *cfg.EthOrchAddr != "" { + ethOrchAddr = ethcommon.HexToAddress(*cfg.EthOrchAddr) + } + + suri, err := getServiceURI(n, *cfg.ServiceAddr, ethOrchAddr) if err != nil { glog.Exit("Error getting service URI: ", err) } @@ -2261,7 +2303,9 @@ func isLocalURL(u string) (bool, error) { // Else: get on-chain sURI // If on-chain sURI mismatches inferred address: print warning // Return on-chain sURI -func getServiceURI(n *core.LivepeerNode, serviceAddr string) (*url.URL, error) { +// ethOrchAddr is the on-chain registered orchestrator whose service URI should be looked +// up. It is the zero address when the node's own account is the orchestrator. +func getServiceURI(n *core.LivepeerNode, serviceAddr string, ethOrchAddr ethcommon.Address) (*url.URL, error) { // Passed in via CLI if serviceAddr != "" { if serviceAddr == "none" { @@ -2298,8 +2342,14 @@ func getServiceURI(n *core.LivepeerNode, serviceAddr string) (*url.URL, error) { return inferredUri, err } - // On-chain lookup and matching with inferred public address - addr, err = n.Eth.GetServiceURI(n.Eth.Account().Address) + // On-chain lookup and matching with inferred public address. + // The service URI is registered against the orchestrator, so a node running on a + // reward caller wallet must not look it up under its own account. + uriAddr := n.Eth.Account().Address + if ethOrchAddr != (ethcommon.Address{}) { + uriAddr = ethOrchAddr + } + addr, err = n.Eth.GetServiceURI(uriAddr) if err != nil { glog.Errorf("Could not get service URI; orchestrator may be unreachable err=%q", err) return nil, err diff --git a/cmd/livepeer/starter/starter_test.go b/cmd/livepeer/starter/starter_test.go index 7f4eff34e6..a190bd1aae 100644 --- a/cmd/livepeer/starter/starter_test.go +++ b/cmd/livepeer/starter/starter_test.go @@ -91,23 +91,23 @@ func TestIsLocalURL(t *testing.T) { } func TestGetServiceURIServiceAddrScheme(t *testing.T) { - uri, err := getServiceURI(nil, "127.0.0.1:8935") + uri, err := getServiceURI(nil, "127.0.0.1:8935", ethcommon.Address{}) require.NoError(t, err) require.Equal(t, "https://127.0.0.1:8935", uri.String()) - uri, err = getServiceURI(nil, "http://127.0.0.1:8935") + uri, err = getServiceURI(nil, "http://127.0.0.1:8935", ethcommon.Address{}) require.NoError(t, err) require.Equal(t, "http://127.0.0.1:8935", uri.String()) - uri, err = getServiceURI(nil, "https://orch.example.com:443") + uri, err = getServiceURI(nil, "https://orch.example.com:443", ethcommon.Address{}) require.NoError(t, err) require.Equal(t, "https://orch.example.com:443", uri.String()) - uri, err = getServiceURI(nil, "gopher://orch.example.com:443") + uri, err = getServiceURI(nil, "gopher://orch.example.com:443", ethcommon.Address{}) require.NoError(t, err) require.Equal(t, "https://gopher://orch.example.com:443", uri.String()) - uri, err = getServiceURI(nil, "none") + uri, err = getServiceURI(nil, "none", ethcommon.Address{}) require.NoError(t, err) require.Equal(t, "", uri.String()) } diff --git a/cmd/livepeer_cli/livepeer_cli.go b/cmd/livepeer_cli/livepeer_cli.go index e73773cfdb..3b200b5170 100644 --- a/cmd/livepeer_cli/livepeer_cli.go +++ b/cmd/livepeer_cli/livepeer_cli.go @@ -97,6 +97,7 @@ func (w *wizard) initializeOptions() []wizardOpt { {desc: "Invoke \"reward\"", invoke: w.callReward, orchestrator: true}, {desc: "Invoke multi-step \"become an orchestrator\"", invoke: w.activateOrchestrator, orchestrator: true}, {desc: "Set orchestrator config", invoke: w.setOrchestratorConfig, orchestrator: true}, + {desc: "Set reward caller", invoke: w.setRewardCaller, orchestrator: true}, {desc: "Invoke \"deposit broadcasting funds\" (ETH)", invoke: w.deposit, notOrchestrator: true}, {desc: "Invoke \"unlock broadcasting funds\"", invoke: w.unlock, notOrchestrator: true}, {desc: "Invoke \"cancel unlock of broadcasting funds\"", invoke: w.cancelUnlock, notOrchestrator: true}, diff --git a/cmd/livepeer_cli/wizard_transcoder.go b/cmd/livepeer_cli/wizard_transcoder.go index 1c5f731ced..4ff21be01b 100644 --- a/cmd/livepeer_cli/wizard_transcoder.go +++ b/cmd/livepeer_cli/wizard_transcoder.go @@ -228,6 +228,67 @@ func (w *wizard) callReward() { httpGet(fmt.Sprintf("http://%v:%v/reward", w.host, w.httpPort)) } +// setRewardCaller authorizes another address to call reward on this orchestrator's behalf +// (LIP-118), so the orchestrator's own wallet can be kept offline. The authorization is +// keyed on msg.sender, so this must be run against a node holding the orchestrator's key. +func (w *wizard) setRewardCaller() { + if w.offchain { + fmt.Println("Cannot set a reward caller in off-chain mode") + return + } + + current := strings.TrimSpace(httpGet(fmt.Sprintf("http://%v:%v/rewardCaller", w.host, w.httpPort))) + + var rewardCaller string + if current != "" && current != "0x0000000000000000000000000000000000000000" { + fmt.Printf("Current reward caller: %v\n", current) + fmt.Print("Unset the current reward caller? (y/n) - ") + if w.readStringYesOrNo() == "y" { + fmt.Printf("Unsetting the reward caller. %v will no longer be able to call reward.\n", current) + } else { + rewardCaller = w.promptRewardCaller() + } + } else { + fmt.Println("No reward caller is currently set") + rewardCaller = w.promptRewardCaller() + } + + val := url.Values{"rewardCaller": {rewardCaller}} + result, ok := httpPostWithParams(fmt.Sprintf("http://%v:%v/setRewardCaller", w.host, w.httpPort), val) + if !ok { + fmt.Printf("Error setting reward caller: %s\n", result) + return + } + + if rewardCaller == "" { + fmt.Println("\nTransaction sent. Once confirmed, no address can call reward on your behalf.") + return + } + fmt.Printf("\nTransaction sent. Once confirmed, run the node with %v's keystore and -ethOrchAddr %v to call reward from it.\n", + rewardCaller, w.getOrchestratorAddr()) +} + +func (w *wizard) promptRewardCaller() string { + fmt.Print("Enter the address that should be allowed to call reward - ") + return w.readStringAndValidate(func(in string) (string, error) { + if !ethcommon.IsHexAddress(in) { + return "", fmt.Errorf("invalid hex address address=%v", in) + } + if ethcommon.HexToAddress(in) == (ethcommon.Address{}) { + return "", fmt.Errorf("cannot set the zero address; answer y to the unset prompt instead") + } + return in, nil + }) +} + +func (w *wizard) getOrchestratorAddr() string { + t, _, err := w.getOrchestratorInfo() + if err != nil || t == nil { + return "" + } + return t.Address.Hex() +} + func (w *wizard) vote() { if w.offchain { glog.Error("Can not vote in 'offchain' mode") diff --git a/eth/client.go b/eth/client.go index c092a80462..1e63663630 100644 --- a/eth/client.go +++ b/eth/client.go @@ -70,6 +70,12 @@ type LivepeerEthClient interface { // Staking Transcoder(blockRewardCut, feeShare *big.Int) (*types.Transaction, error) Reward() (*types.Transaction, error) + // Reward caller (LIP-118). RewardForTranscoder is called by an address the + // transcoder authorized via SetRewardCaller, so the transcoder's own wallet + // can stay offline. Passing the zero address to SetRewardCaller unsets it. + SetRewardCaller(rewardCaller ethcommon.Address) (*types.Transaction, error) + GetRewardCaller(transcoder ethcommon.Address) (ethcommon.Address, error) + RewardForTranscoder(transcoder ethcommon.Address) (*types.Transaction, error) Bond(amount *big.Int, toAddr ethcommon.Address) (*types.Transaction, error) Rebond(unbondingLockID *big.Int) (*types.Transaction, error) RebondFromUnbonded(toAddr ethcommon.Address, unbondingLockID *big.Int) (*types.Transaction, error) @@ -1021,7 +1027,23 @@ func (c *client) ProposalVoteWithReason(proposalId *big.Int, support uint8, reas } func (c *client) Reward() (*types.Transaction, error) { - addr := c.accountManager.Account().Address + return c.rewardFor(c.accountManager.Account().Address, false) +} + +// RewardForTranscoder calls reward on behalf of `transcoder`. The node's account must +// have been authorized by `transcoder` via SetRewardCaller, otherwise the transaction +// reverts with "caller must be a reward caller set by the transcoder". +func (c *client) RewardForTranscoder(transcoder ethcommon.Address) (*types.Transaction, error) { + return c.rewardFor(transcoder, true) +} + +// rewardFor computes the transcoder pool position hints for `transcoder` and submits the +// reward transaction. The hint math is identical for both paths and must always be based +// on the transcoder's stake, never the caller's. When `delegated` is set the transaction +// is submitted through rewardForTranscoderWithHint, which resolves the authorized caller +// on-chain; otherwise the caller is the transcoder itself. +func (c *client) rewardFor(transcoder ethcommon.Address, delegated bool) (*types.Transaction, error) { + addr := transcoder tr, err := c.GetTranscoder(addr) if err != nil { @@ -1065,9 +1087,26 @@ func (c *client) Reward() (*types.Transaction, error) { hints := simulateTranscoderPoolUpdate(addr, reward.Add(reward, tr.DelegatedStake), transcoders, len(transcoders) == int(maxSize.Int64())) + if delegated { + return c.bondingManager.RewardForTranscoderWithHint(c.transactOpts(), addr, hints.PosPrev, hints.PosNext) + } + return c.bondingManager.RewardWithHint(c.transactOpts(), hints.PosPrev, hints.PosNext) } +// SetRewardCaller authorizes `rewardCaller` to call reward on behalf of the node's +// account. Passing the zero address unsets any existing authorization. This must be sent +// from the transcoder's own wallet; the contract keys the mapping on msg.sender. +func (c *client) SetRewardCaller(rewardCaller ethcommon.Address) (*types.Transaction, error) { + return c.bondingManager.SetRewardCaller(c.transactOpts(), rewardCaller) +} + +// GetRewardCaller returns the address `transcoder` authorized to call reward on its +// behalf, or the zero address if none is set. +func (c *client) GetRewardCaller(transcoder ethcommon.Address) (ethcommon.Address, error) { + return c.bondingManager.TranscoderToRewardCaller(c.callOpts(), transcoder) +} + func (c *client) WithdrawFees(addr ethcommon.Address, amount *big.Int) (*types.Transaction, error) { return c.bondingManager.WithdrawFees(c.transactOpts(), addr, amount) } diff --git a/eth/contracts/bondingManager.go b/eth/contracts/bondingManager.go index 8dcd1a2538..ba687c51f9 100644 --- a/eth/contracts/bondingManager.go +++ b/eth/contracts/bondingManager.go @@ -31,7 +31,7 @@ var ( // BondingManagerMetaData contains all meta data concerning the BondingManager contract. var BondingManagerMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_controller\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newDelegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"oldDelegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"additionalAmount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"bondedAmount\",\"type\":\"uint256\"}],\"name\":\"Bond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"rewards\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"fees\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"startRound\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"endRound\",\"type\":\"uint256\"}],\"name\":\"EarningsClaimed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"string\",\"name\":\"param\",\"type\":\"string\"}],\"name\":\"ParameterUpdate\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"unbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Rebond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Reward\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"controller\",\"type\":\"address\"}],\"name\":\"SetController\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"activationRound\",\"type\":\"uint256\"}],\"name\":\"TranscoderActivated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"deactivationRound\",\"type\":\"uint256\"}],\"name\":\"TranscoderDeactivated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"finder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"penalty\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"finderReward\",\"type\":\"uint256\"}],\"name\":\"TranscoderSlashed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"rewardCut\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"feeShare\",\"type\":\"uint256\"}],\"name\":\"TranscoderUpdate\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"oldDelegator\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newDelegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"oldUnbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"newUnbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"TransferBond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"TreasuryReward\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"unbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"withdrawRound\",\"type\":\"uint256\"}],\"name\":\"Unbond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"recipient\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"WithdrawFees\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"unbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"withdrawRound\",\"type\":\"uint256\"}],\"name\":\"WithdrawStake\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"}],\"name\":\"bond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosNext\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosNext\",\"type\":\"address\"}],\"name\":\"bondForWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosNext\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosNext\",\"type\":\"address\"}],\"name\":\"bondWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_account\",\"type\":\"address\"}],\"name\":\"checkpointBondingState\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_endRound\",\"type\":\"uint256\"}],\"name\":\"claimEarnings\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"controller\",\"outputs\":[{\"internalType\":\"contractIController\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"currentRoundTotalActiveStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"}],\"name\":\"delegatorStatus\",\"outputs\":[{\"internalType\":\"enumBondingManager.DelegatorStatus\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"}],\"name\":\"getDelegator\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"bondedAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"fees\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"delegateAddress\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"delegatedAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"startRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"lastClaimRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"nextUnbondingLockId\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"getDelegatorUnbondingLock\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"withdrawRound\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getFirstTranscoderInPool\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"getNextTranscoderInPool\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTotalBonded\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"getTranscoder\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"lastRewardRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"rewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"feeShare\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"lastActiveStakeUpdateRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"activationRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"deactivationRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"activeCumulativeRewards\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeRewards\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeFees\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"lastFeeRound\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_round\",\"type\":\"uint256\"}],\"name\":\"getTranscoderEarningsPoolForRound\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"totalStake\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"transcoderRewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"transcoderFeeShare\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeRewardFactor\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeFeeFactor\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTranscoderPoolMaxSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTranscoderPoolSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"isActiveTranscoder\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"isRegisteredTranscoder\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"isValidUnbondingLock\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"nextRoundTotalActiveStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"nextRoundTreasuryRewardCutRate\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_endRound\",\"type\":\"uint256\"}],\"name\":\"pendingFees\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_endRound\",\"type\":\"uint256\"}],\"name\":\"pendingStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"rebond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"rebondFromUnbonded\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rebondFromUnbondedWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rebondWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reward\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rewardWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_controller\",\"type\":\"address\"}],\"name\":\"setController\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"setCurrentRoundTotalActiveStake\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_numActiveTranscoders\",\"type\":\"uint256\"}],\"name\":\"setNumActiveTranscoders\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_ceiling\",\"type\":\"uint256\"}],\"name\":\"setTreasuryBalanceCeiling\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_cutRate\",\"type\":\"uint256\"}],\"name\":\"setTreasuryRewardCutRate\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"_unbondingPeriod\",\"type\":\"uint64\"}],\"name\":\"setUnbondingPeriod\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_finder\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_slashAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_finderFee\",\"type\":\"uint256\"}],\"name\":\"slashTranscoder\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"targetContractId\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_rewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_feeShare\",\"type\":\"uint256\"}],\"name\":\"transcoder\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"transcoderStatus\",\"outputs\":[{\"internalType\":\"enumBondingManager.TranscoderStatus\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"transcoderTotalStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_rewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_feeShare\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"transcoderWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosNext\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newDelegateNewPosNext\",\"type\":\"address\"}],\"name\":\"transferBond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasuryBalanceCeiling\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasuryRewardCutRate\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"unbond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"unbondWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"unbondingPeriod\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_fees\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_round\",\"type\":\"uint256\"}],\"name\":\"updateTranscoderWithFees\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"addresspayable\",\"name\":\"_recipient\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"withdrawFees\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"withdrawStake\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_controller\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newDelegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"oldDelegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"additionalAmount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"bondedAmount\",\"type\":\"uint256\"}],\"name\":\"Bond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"rewards\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"fees\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"startRound\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"endRound\",\"type\":\"uint256\"}],\"name\":\"EarningsClaimed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"string\",\"name\":\"param\",\"type\":\"string\"}],\"name\":\"ParameterUpdate\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"unbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Rebond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"Reward\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"rewardCaller\",\"type\":\"address\"}],\"name\":\"RewardCallerSet\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"controller\",\"type\":\"address\"}],\"name\":\"SetController\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"activationRound\",\"type\":\"uint256\"}],\"name\":\"TranscoderActivated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"deactivationRound\",\"type\":\"uint256\"}],\"name\":\"TranscoderDeactivated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"finder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"penalty\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"finderReward\",\"type\":\"uint256\"}],\"name\":\"TranscoderSlashed\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"rewardCut\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"feeShare\",\"type\":\"uint256\"}],\"name\":\"TranscoderUpdate\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"oldDelegator\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newDelegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"oldUnbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"newUnbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"TransferBond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"transcoder\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"treasury\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"TreasuryReward\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegate\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"unbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"withdrawRound\",\"type\":\"uint256\"}],\"name\":\"Unbond\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"address\",\"name\":\"recipient\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"WithdrawFees\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"delegator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"unbondingLockId\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"withdrawRound\",\"type\":\"uint256\"}],\"name\":\"WithdrawStake\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"}],\"name\":\"bond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosNext\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosNext\",\"type\":\"address\"}],\"name\":\"bondForWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosNext\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_currDelegateNewPosNext\",\"type\":\"address\"}],\"name\":\"bondWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_account\",\"type\":\"address\"}],\"name\":\"checkpointBondingState\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_endRound\",\"type\":\"uint256\"}],\"name\":\"claimEarnings\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"controller\",\"outputs\":[{\"internalType\":\"contractIController\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"currentRoundTotalActiveStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"}],\"name\":\"delegatorStatus\",\"outputs\":[{\"internalType\":\"enumBondingManager.DelegatorStatus\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"}],\"name\":\"getDelegator\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"bondedAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"fees\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"delegateAddress\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"delegatedAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"startRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"lastClaimRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"nextUnbondingLockId\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"getDelegatorUnbondingLock\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"withdrawRound\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getFirstTranscoderInPool\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"getNextTranscoderInPool\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTotalBonded\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"getTranscoder\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"lastRewardRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"rewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"feeShare\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"lastActiveStakeUpdateRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"activationRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"deactivationRound\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"activeCumulativeRewards\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeRewards\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeFees\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"lastFeeRound\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_round\",\"type\":\"uint256\"}],\"name\":\"getTranscoderEarningsPoolForRound\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"totalStake\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"transcoderRewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"transcoderFeeShare\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeRewardFactor\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"cumulativeFeeFactor\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTranscoderPoolMaxSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getTranscoderPoolSize\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"isActiveTranscoder\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"isRegisteredTranscoder\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"isValidUnbondingLock\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"nextRoundTotalActiveStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"nextRoundTreasuryRewardCutRate\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_endRound\",\"type\":\"uint256\"}],\"name\":\"pendingFees\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_endRound\",\"type\":\"uint256\"}],\"name\":\"pendingStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"rebond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"rebondFromUnbonded\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rebondFromUnbondedWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rebondWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reward\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"rewardForTranscoder\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rewardForTranscoderWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"rewardWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_controller\",\"type\":\"address\"}],\"name\":\"setController\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"setCurrentRoundTotalActiveStake\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_numActiveTranscoders\",\"type\":\"uint256\"}],\"name\":\"setNumActiveTranscoders\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_rewardCaller\",\"type\":\"address\"}],\"name\":\"setRewardCaller\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_ceiling\",\"type\":\"uint256\"}],\"name\":\"setTreasuryBalanceCeiling\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_cutRate\",\"type\":\"uint256\"}],\"name\":\"setTreasuryRewardCutRate\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"_unbondingPeriod\",\"type\":\"uint64\"}],\"name\":\"setUnbondingPeriod\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_finder\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_slashAmount\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_finderFee\",\"type\":\"uint256\"}],\"name\":\"slashTranscoder\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"targetContractId\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_rewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_feeShare\",\"type\":\"uint256\"}],\"name\":\"transcoder\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"transcoderStatus\",\"outputs\":[{\"internalType\":\"enumBondingManager.TranscoderStatus\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"transcoderToRewardCaller\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"}],\"name\":\"transcoderTotalStake\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_rewardCut\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_feeShare\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"transcoderWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_delegator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_oldDelegateNewPosNext\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newDelegateNewPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newDelegateNewPosNext\",\"type\":\"address\"}],\"name\":\"transferBond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasuryBalanceCeiling\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"treasuryRewardCutRate\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"unbond\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_newPosPrev\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_newPosNext\",\"type\":\"address\"}],\"name\":\"unbondWithHint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"unbondingPeriod\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_transcoder\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_fees\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_round\",\"type\":\"uint256\"}],\"name\":\"updateTranscoderWithFees\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"addresspayable\",\"name\":\"_recipient\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"withdrawFees\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_unbondingLockId\",\"type\":\"uint256\"}],\"name\":\"withdrawStake\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", } // BondingManagerABI is the input ABI used to generate the binding from. @@ -967,6 +967,37 @@ func (_BondingManager *BondingManagerCallerSession) TranscoderStatus(_transcoder return _BondingManager.Contract.TranscoderStatus(&_BondingManager.CallOpts, _transcoder) } +// TranscoderToRewardCaller is a free data retrieval call binding the contract method 0x47621410. +// +// Solidity: function transcoderToRewardCaller(address ) view returns(address) +func (_BondingManager *BondingManagerCaller) TranscoderToRewardCaller(opts *bind.CallOpts, arg0 common.Address) (common.Address, error) { + var out []interface{} + err := _BondingManager.contract.Call(opts, &out, "transcoderToRewardCaller", arg0) + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// TranscoderToRewardCaller is a free data retrieval call binding the contract method 0x47621410. +// +// Solidity: function transcoderToRewardCaller(address ) view returns(address) +func (_BondingManager *BondingManagerSession) TranscoderToRewardCaller(arg0 common.Address) (common.Address, error) { + return _BondingManager.Contract.TranscoderToRewardCaller(&_BondingManager.CallOpts, arg0) +} + +// TranscoderToRewardCaller is a free data retrieval call binding the contract method 0x47621410. +// +// Solidity: function transcoderToRewardCaller(address ) view returns(address) +func (_BondingManager *BondingManagerCallerSession) TranscoderToRewardCaller(arg0 common.Address) (common.Address, error) { + return _BondingManager.Contract.TranscoderToRewardCaller(&_BondingManager.CallOpts, arg0) +} + // TranscoderTotalStake is a free data retrieval call binding the contract method 0x9ef9df94. // // Solidity: function transcoderTotalStake(address _transcoder) view returns(uint256) @@ -1301,6 +1332,48 @@ func (_BondingManager *BondingManagerTransactorSession) Reward() (*types.Transac return _BondingManager.Contract.Reward(&_BondingManager.TransactOpts) } +// RewardForTranscoder is a paid mutator transaction binding the contract method 0x1a8c41f8. +// +// Solidity: function rewardForTranscoder(address _transcoder) returns() +func (_BondingManager *BondingManagerTransactor) RewardForTranscoder(opts *bind.TransactOpts, _transcoder common.Address) (*types.Transaction, error) { + return _BondingManager.contract.Transact(opts, "rewardForTranscoder", _transcoder) +} + +// RewardForTranscoder is a paid mutator transaction binding the contract method 0x1a8c41f8. +// +// Solidity: function rewardForTranscoder(address _transcoder) returns() +func (_BondingManager *BondingManagerSession) RewardForTranscoder(_transcoder common.Address) (*types.Transaction, error) { + return _BondingManager.Contract.RewardForTranscoder(&_BondingManager.TransactOpts, _transcoder) +} + +// RewardForTranscoder is a paid mutator transaction binding the contract method 0x1a8c41f8. +// +// Solidity: function rewardForTranscoder(address _transcoder) returns() +func (_BondingManager *BondingManagerTransactorSession) RewardForTranscoder(_transcoder common.Address) (*types.Transaction, error) { + return _BondingManager.Contract.RewardForTranscoder(&_BondingManager.TransactOpts, _transcoder) +} + +// RewardForTranscoderWithHint is a paid mutator transaction binding the contract method 0xb556966a. +// +// Solidity: function rewardForTranscoderWithHint(address _transcoder, address _newPosPrev, address _newPosNext) returns() +func (_BondingManager *BondingManagerTransactor) RewardForTranscoderWithHint(opts *bind.TransactOpts, _transcoder common.Address, _newPosPrev common.Address, _newPosNext common.Address) (*types.Transaction, error) { + return _BondingManager.contract.Transact(opts, "rewardForTranscoderWithHint", _transcoder, _newPosPrev, _newPosNext) +} + +// RewardForTranscoderWithHint is a paid mutator transaction binding the contract method 0xb556966a. +// +// Solidity: function rewardForTranscoderWithHint(address _transcoder, address _newPosPrev, address _newPosNext) returns() +func (_BondingManager *BondingManagerSession) RewardForTranscoderWithHint(_transcoder common.Address, _newPosPrev common.Address, _newPosNext common.Address) (*types.Transaction, error) { + return _BondingManager.Contract.RewardForTranscoderWithHint(&_BondingManager.TransactOpts, _transcoder, _newPosPrev, _newPosNext) +} + +// RewardForTranscoderWithHint is a paid mutator transaction binding the contract method 0xb556966a. +// +// Solidity: function rewardForTranscoderWithHint(address _transcoder, address _newPosPrev, address _newPosNext) returns() +func (_BondingManager *BondingManagerTransactorSession) RewardForTranscoderWithHint(_transcoder common.Address, _newPosPrev common.Address, _newPosNext common.Address) (*types.Transaction, error) { + return _BondingManager.Contract.RewardForTranscoderWithHint(&_BondingManager.TransactOpts, _transcoder, _newPosPrev, _newPosNext) +} + // RewardWithHint is a paid mutator transaction binding the contract method 0x81871056. // // Solidity: function rewardWithHint(address _newPosPrev, address _newPosNext) returns() @@ -1385,6 +1458,27 @@ func (_BondingManager *BondingManagerTransactorSession) SetNumActiveTranscoders( return _BondingManager.Contract.SetNumActiveTranscoders(&_BondingManager.TransactOpts, _numActiveTranscoders) } +// SetRewardCaller is a paid mutator transaction binding the contract method 0x0103e60e. +// +// Solidity: function setRewardCaller(address _rewardCaller) returns() +func (_BondingManager *BondingManagerTransactor) SetRewardCaller(opts *bind.TransactOpts, _rewardCaller common.Address) (*types.Transaction, error) { + return _BondingManager.contract.Transact(opts, "setRewardCaller", _rewardCaller) +} + +// SetRewardCaller is a paid mutator transaction binding the contract method 0x0103e60e. +// +// Solidity: function setRewardCaller(address _rewardCaller) returns() +func (_BondingManager *BondingManagerSession) SetRewardCaller(_rewardCaller common.Address) (*types.Transaction, error) { + return _BondingManager.Contract.SetRewardCaller(&_BondingManager.TransactOpts, _rewardCaller) +} + +// SetRewardCaller is a paid mutator transaction binding the contract method 0x0103e60e. +// +// Solidity: function setRewardCaller(address _rewardCaller) returns() +func (_BondingManager *BondingManagerTransactorSession) SetRewardCaller(_rewardCaller common.Address) (*types.Transaction, error) { + return _BondingManager.Contract.SetRewardCaller(&_BondingManager.TransactOpts, _rewardCaller) +} + // SetTreasuryBalanceCeiling is a paid mutator transaction binding the contract method 0x40e6f271. // // Solidity: function setTreasuryBalanceCeiling(uint256 _ceiling) returns() @@ -2392,6 +2486,159 @@ func (_BondingManager *BondingManagerFilterer) ParseReward(log types.Log) (*Bond return event, nil } +// BondingManagerRewardCallerSetIterator is returned from FilterRewardCallerSet and is used to iterate over the raw logs and unpacked data for RewardCallerSet events raised by the BondingManager contract. +type BondingManagerRewardCallerSetIterator struct { + Event *BondingManagerRewardCallerSet // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *BondingManagerRewardCallerSetIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(BondingManagerRewardCallerSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(BondingManagerRewardCallerSet) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *BondingManagerRewardCallerSetIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *BondingManagerRewardCallerSetIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// BondingManagerRewardCallerSet represents a RewardCallerSet event raised by the BondingManager contract. +type BondingManagerRewardCallerSet struct { + Transcoder common.Address + RewardCaller common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterRewardCallerSet is a free log retrieval operation binding the contract event 0x15932b87747cac36d3c505e86a946c6d3a79d9d7bdb79d81584d32bbe54516c2. +// +// Solidity: event RewardCallerSet(address indexed transcoder, address indexed rewardCaller) +func (_BondingManager *BondingManagerFilterer) FilterRewardCallerSet(opts *bind.FilterOpts, transcoder []common.Address, rewardCaller []common.Address) (*BondingManagerRewardCallerSetIterator, error) { + + var transcoderRule []interface{} + for _, transcoderItem := range transcoder { + transcoderRule = append(transcoderRule, transcoderItem) + } + var rewardCallerRule []interface{} + for _, rewardCallerItem := range rewardCaller { + rewardCallerRule = append(rewardCallerRule, rewardCallerItem) + } + + logs, sub, err := _BondingManager.contract.FilterLogs(opts, "RewardCallerSet", transcoderRule, rewardCallerRule) + if err != nil { + return nil, err + } + return &BondingManagerRewardCallerSetIterator{contract: _BondingManager.contract, event: "RewardCallerSet", logs: logs, sub: sub}, nil +} + +// WatchRewardCallerSet is a free log subscription operation binding the contract event 0x15932b87747cac36d3c505e86a946c6d3a79d9d7bdb79d81584d32bbe54516c2. +// +// Solidity: event RewardCallerSet(address indexed transcoder, address indexed rewardCaller) +func (_BondingManager *BondingManagerFilterer) WatchRewardCallerSet(opts *bind.WatchOpts, sink chan<- *BondingManagerRewardCallerSet, transcoder []common.Address, rewardCaller []common.Address) (event.Subscription, error) { + + var transcoderRule []interface{} + for _, transcoderItem := range transcoder { + transcoderRule = append(transcoderRule, transcoderItem) + } + var rewardCallerRule []interface{} + for _, rewardCallerItem := range rewardCaller { + rewardCallerRule = append(rewardCallerRule, rewardCallerItem) + } + + logs, sub, err := _BondingManager.contract.WatchLogs(opts, "RewardCallerSet", transcoderRule, rewardCallerRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(BondingManagerRewardCallerSet) + if err := _BondingManager.contract.UnpackLog(event, "RewardCallerSet", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseRewardCallerSet is a log parse operation binding the contract event 0x15932b87747cac36d3c505e86a946c6d3a79d9d7bdb79d81584d32bbe54516c2. +// +// Solidity: event RewardCallerSet(address indexed transcoder, address indexed rewardCaller) +func (_BondingManager *BondingManagerFilterer) ParseRewardCallerSet(log types.Log) (*BondingManagerRewardCallerSet, error) { + event := new(BondingManagerRewardCallerSet) + if err := _BondingManager.contract.UnpackLog(event, "RewardCallerSet", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // BondingManagerSetControllerIterator is returned from FilterSetController and is used to iterate over the raw logs and unpacked data for SetController events raised by the BondingManager contract. type BondingManagerSetControllerIterator struct { Event *BondingManagerSetController // Event containing the contract specifics and raw log diff --git a/eth/rewardservice.go b/eth/rewardservice.go index bd10a062a3..ef709eb622 100644 --- a/eth/rewardservice.go +++ b/eth/rewardservice.go @@ -3,8 +3,10 @@ package eth import ( "context" "fmt" + "math/big" "sync" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/golang/glog" "github.com/livepeer/go-livepeer/monitor" @@ -16,17 +18,24 @@ var ( ) type RewardService struct { - client LivepeerEthClient - working bool + client LivepeerEthClient + working bool + // orchAddr is the transcoder reward is called for. It is the zero address when the + // node's own account is the transcoder; otherwise the node acts as a reward caller + // authorized by orchAddr (LIP-118). + orchAddr ethcommon.Address cancelWorker context.CancelFunc tw timeWatcher mu sync.Mutex } -func NewRewardService(client LivepeerEthClient, tw timeWatcher) *RewardService { +// NewRewardService returns a service that calls reward once per round for orchAddr. +// Pass the zero address when the node's own account is the registered transcoder. +func NewRewardService(client LivepeerEthClient, tw timeWatcher, orchAddr ethcommon.Address) *RewardService { return &RewardService{ - client: client, - tw: tw, + client: client, + tw: tw, + orchAddr: orchAddr, } } @@ -91,25 +100,59 @@ func (s *RewardService) tryReward() error { currentRound := s.tw.LastInitializedRound() - t, err := s.client.GetTranscoder(s.client.Account().Address) + account := s.client.Account().Address + // Eligibility is always a property of the transcoder, never of the caller. Reading + // the caller's record here would return an empty, inactive transcoder and silently + // skip the reward call for the whole round. + transcoder := s.orchAddr + delegated := transcoder != (ethcommon.Address{}) && transcoder != account + if !delegated { + transcoder = account + } + + t, err := s.client.GetTranscoder(transcoder) if err != nil { return err } - if t.LastRewardRound.Cmp(currentRound) == -1 && t.Active { - tx, err := s.client.Reward() - if err != nil { - return err - } + if t.LastRewardRound.Cmp(currentRound) != -1 || !t.Active { + return nil + } - if err := s.client.CheckTx(tx); err != nil { - return err - } + if !delegated { + return s.sendReward(currentRound, func() (*types.Transaction, error) { return s.client.Reward() }) + } - glog.Infof("Called reward for round %v", currentRound) + // Re-check authorization every round so a revoked reward caller surfaces as a clear + // error rather than a bare on-chain revert. + rewardCaller, err := s.client.GetRewardCaller(transcoder) + if err != nil { + return fmt.Errorf("could not look up reward caller for transcoder %v: %w", transcoder.Hex(), err) + } + if rewardCaller != account { + return fmt.Errorf( + "node account %v is not the reward caller for transcoder %v (on-chain reward caller is %v); "+ + "set it with livepeer_cli from the transcoder's wallet", + account.Hex(), transcoder.Hex(), rewardCaller.Hex(), + ) + } - return nil + return s.sendReward(currentRound, func() (*types.Transaction, error) { + return s.client.RewardForTranscoder(transcoder) + }) +} + +func (s *RewardService) sendReward(currentRound *big.Int, send func() (*types.Transaction, error)) error { + tx, err := send() + if err != nil { + return err } + if err := s.client.CheckTx(tx); err != nil { + return err + } + + glog.Infof("Called reward for round %v", currentRound) + return nil } diff --git a/eth/rewardservice_test.go b/eth/rewardservice_test.go index b707ecd309..5697673383 100644 --- a/eth/rewardservice_test.go +++ b/eth/rewardservice_test.go @@ -2,11 +2,13 @@ package eth import ( "context" - ethcommon "github.com/ethereum/go-ethereum/common" + "errors" "math/big" "testing" "time" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/accounts" "github.com/ethereum/go-ethereum/core/types" "github.com/golang/glog" @@ -127,3 +129,151 @@ func TestRewardService_ReceiveRoundEvent_TryReward(t *testing.T) { assert.Equal(int64(1), errorLogsAfter-errorLogsBefore) assert.Equal(int64(0), infoLogsAfter-infoLogsBefore) } + +func TestRewardService_TryReward_RewardCaller(t *testing.T) { + var ( + account = ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + orch = ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + other = ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + + eligible = &lpTypes.Transcoder{LastRewardRound: big.NewInt(1), Active: true} + rewarded = &lpTypes.Transcoder{LastRewardRound: big.NewInt(100), Active: true} + inactive = &lpTypes.Transcoder{LastRewardRound: big.NewInt(1), Active: false} + currentRnd = big.NewInt(100) + ) + + tests := []struct { + name string + // orchAddr configured on the service + orchAddr ethcommon.Address + // transcoder records keyed by address + accountRecord *lpTypes.Transcoder + orchRecord *lpTypes.Transcoder + // on-chain reward caller for orch + rewardCaller ethcommon.Address + + wantReward bool + wantRewardForTranscoder bool + wantErr string + }{ + { + name: "not delegated, zero orchAddr, calls reward directly", + orchAddr: ethcommon.Address{}, + accountRecord: eligible, + wantReward: true, + }, + { + name: "not delegated, orchAddr equals account, calls reward directly", + orchAddr: account, + accountRecord: eligible, + wantReward: true, + }, + { + name: "delegated and authorized, calls rewardForTranscoder", + orchAddr: orch, + orchRecord: eligible, + rewardCaller: account, + wantRewardForTranscoder: true, + }, + { + name: "delegated but not authorized, errors without sending a tx", + orchAddr: orch, + orchRecord: eligible, + rewardCaller: other, + wantErr: "is not the reward caller", + }, + { + name: "delegated and revoked, errors without sending a tx", + orchAddr: orch, + orchRecord: eligible, + rewardCaller: ethcommon.Address{}, + wantErr: "is not the reward caller", + }, + { + // The caller's own record is eligible; the orchestrator's is not. Eligibility + // must be read from the orchestrator or the node would reward twice a round. + name: "delegated, eligibility read from orchestrator not caller", + orchAddr: orch, + accountRecord: eligible, + orchRecord: rewarded, + rewardCaller: account, + }, + { + name: "delegated, orchestrator inactive", + orchAddr: orch, + accountRecord: eligible, + orchRecord: inactive, + rewardCaller: account, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + client := &MockClient{} + rs := RewardService{ + client: client, + tw: &stubTimeWatcher{lastInitializedRound: currentRnd}, + orchAddr: tt.orchAddr, + } + + client.On("Account").Return(accounts.Account{Address: account}) + if tt.accountRecord != nil { + client.On("GetTranscoder", account).Return(tt.accountRecord, nil) + } + if tt.orchRecord != nil { + client.On("GetTranscoder", orch).Return(tt.orchRecord, nil) + } + client.On("GetRewardCaller", orch).Return(tt.rewardCaller, nil) + client.On("Reward").Return(&types.Transaction{}, nil) + client.On("RewardForTranscoder", orch).Return(&types.Transaction{}, nil) + client.On("CheckTx").Return(nil) + client.On("GetTranscoderEarningsPoolForRound").Return(&lpTypes.TokenPools{}, nil) + + err := rs.tryReward() + + if tt.wantErr != "" { + assert.ErrorContains(err, tt.wantErr) + } else { + assert.NoError(err) + } + + if tt.wantReward { + client.AssertNumberOfCalls(t, "Reward", 1) + } else { + client.AssertNumberOfCalls(t, "Reward", 0) + } + if tt.wantRewardForTranscoder { + client.AssertCalled(t, "RewardForTranscoder", orch) + } else { + client.AssertNumberOfCalls(t, "RewardForTranscoder", 0) + } + }) + } +} + +func TestRewardService_TryReward_RewardCallerLookupError(t *testing.T) { + assert := assert.New(t) + account := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + orch := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + + client := &MockClient{} + rs := RewardService{ + client: client, + tw: &stubTimeWatcher{lastInitializedRound: big.NewInt(100)}, + orchAddr: orch, + } + + client.On("Account").Return(accounts.Account{Address: account}) + client.On("GetTranscoder", orch).Return(&lpTypes.Transcoder{LastRewardRound: big.NewInt(1), Active: true}, nil) + client.On("GetRewardCaller", orch).Return(ethcommon.Address{}, errors.New("rpc boom")) + client.On("Reward").Return(&types.Transaction{}, nil) + client.On("RewardForTranscoder", orch).Return(&types.Transaction{}, nil) + + err := rs.tryReward() + + assert.ErrorContains(err, "could not look up reward caller") + assert.ErrorContains(err, "rpc boom") + client.AssertNumberOfCalls(t, "Reward", 0) + client.AssertNumberOfCalls(t, "RewardForTranscoder", 0) +} diff --git a/eth/stubclient.go b/eth/stubclient.go index a5204ef4dd..3f6cd0c27d 100644 --- a/eth/stubclient.go +++ b/eth/stubclient.go @@ -100,6 +100,21 @@ func (m *MockClient) Reward() (*types.Transaction, error) { return mockTransaction(args, 0), args.Error(1) } +func (m *MockClient) RewardForTranscoder(transcoder common.Address) (*types.Transaction, error) { + args := m.Called(transcoder) + return mockTransaction(args, 0), args.Error(1) +} + +func (m *MockClient) SetRewardCaller(rewardCaller common.Address) (*types.Transaction, error) { + args := m.Called(rewardCaller) + return mockTransaction(args, 0), args.Error(1) +} + +func (m *MockClient) GetRewardCaller(transcoder common.Address) (common.Address, error) { + args := m.Called(transcoder) + return args.Get(0).(common.Address), args.Error(1) +} + func (m *MockClient) GetTranscoderEarningsPoolForRound(address common.Address, round *big.Int) (*lpTypes.TokenPools, error) { args := m.Called() return args.Get(0).(*lpTypes.TokenPools), args.Error(1) @@ -266,6 +281,17 @@ type StubClient struct { RoundLocked bool RoundLockedErr error Errors map[string]error + + // OrchByAddr lets a test distinguish the transcoder record of the node's own + // account from that of a separate orchestrator, which is required to exercise + // the reward caller paths. GetTranscoder falls back to Orch when unset. + OrchByAddr map[common.Address]*lpTypes.Transcoder + // RewardCallers maps a transcoder to the address it authorized to call reward. + RewardCallers map[common.Address]common.Address + // RewardedTranscoder records the argument of the last RewardForTranscoder call. + RewardedTranscoder common.Address + // LastSetRewardCaller records the argument of the last SetRewardCaller call. + LastSetRewardCaller common.Address } type stubTranscoder struct { @@ -324,7 +350,21 @@ func (e *StubClient) GetServiceURI(addr common.Address) (string, error) { func (e *StubClient) Transcoder(blockRewardCut, feeShare *big.Int) (*types.Transaction, error) { return nil, nil } -func (e *StubClient) Reward() (*types.Transaction, error) { return nil, nil } +func (e *StubClient) Reward() (*types.Transaction, error) { return nil, e.Errors["Reward"] } +func (e *StubClient) RewardForTranscoder(transcoder common.Address) (*types.Transaction, error) { + e.RewardedTranscoder = transcoder + return nil, e.Errors["RewardForTranscoder"] +} +func (e *StubClient) SetRewardCaller(rewardCaller common.Address) (*types.Transaction, error) { + e.LastSetRewardCaller = rewardCaller + return nil, e.Errors["SetRewardCaller"] +} +func (e *StubClient) GetRewardCaller(transcoder common.Address) (common.Address, error) { + if err := e.Errors["GetRewardCaller"]; err != nil { + return common.Address{}, err + } + return e.RewardCallers[transcoder], nil +} func (e *StubClient) Bond(amount *big.Int, toAddr common.Address) (*types.Transaction, error) { return nil, nil } @@ -351,6 +391,9 @@ func (e *StubClient) GetTranscoder(addr common.Address) (*lpTypes.Transcoder, er if e.Err != nil { return nil, e.Err } + if t, ok := e.OrchByAddr[addr]; ok { + return t, nil + } return e.Orch, nil } func (e *StubClient) GetDelegator(addr common.Address) (*lpTypes.Delegator, error) { return nil, nil } diff --git a/server/handlers.go b/server/handlers.go index b6ac1928e7..efba1bfd54 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -103,7 +103,13 @@ type orchInfo struct { func (s *LivepeerServer) orchestratorInfoHandler(client eth.LivepeerEthClient) http.Handler { return mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - addr := client.Account().Address + // Report the orchestrator's record, not the node account's. On a delegated + // wallet the account is not a transcoder, so reading it would return an empty + // record and make the CLI show a stale status, stake and LastRewardRound. + addr, delegated := s.delegatedOrchestrator(client) + if !delegated { + addr = client.Account().Address + } t, err := client.GetTranscoder(addr) if err != nil { respond500(w, "could not get transcoder") @@ -451,8 +457,46 @@ func roundInitializedHandler(client eth.LivepeerEthClient) http.Handler { } // Orchestrator registration/activation +// delegatedOrchestrator returns the on-chain orchestrator this node acts for and true when +// that differs from the node's own account, i.e. the node runs on a delegated wallet such +// as a LIP-118 reward caller. It returns false for the common case where the node's own +// account is the orchestrator. +func (s *LivepeerServer) delegatedOrchestrator(client eth.LivepeerEthClient) (ethcommon.Address, bool) { + if s.LivepeerNode == nil || s.LivepeerNode.RecipientAddr == "" { + return ethcommon.Address{}, false + } + orch := ethcommon.HexToAddress(s.LivepeerNode.RecipientAddr) + if orch == client.Account().Address { + return ethcommon.Address{}, false + } + return orch, true +} + +// rejectIfDelegatedWallet blocks actions that the contracts key on msg.sender and that +// therefore only make sense from the orchestrator's own wallet. Without this guard a node +// running on a reward caller wallet would send setServiceURI transactions that succeed but +// write to the caller's key -- the ServiceRegistry has no caller check of its own -- and +// reward cut / fee share transactions that revert. Returns true when the request was +// answered and the caller should stop. +func (s *LivepeerServer) rejectIfDelegatedWallet(w http.ResponseWriter, client eth.LivepeerEthClient) bool { + orch, delegated := s.delegatedOrchestrator(client) + if !delegated { + return false + } + respond400(w, fmt.Sprintf( + "this node runs on wallet %v, which is not orchestrator %v; "+ + "orchestrator configuration must be sent from the orchestrator's own wallet", + client.Account().Address.Hex(), orch.Hex(), + )) + return true +} + func (s *LivepeerServer) activateOrchestratorHandler(client eth.LivepeerEthClient) http.Handler { return mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.rejectIfDelegatedWallet(w, client) { + return + } + t, err := client.GetTranscoder(client.Account().Address) if err != nil { glog.Error(err) @@ -583,6 +627,10 @@ func (s *LivepeerServer) activateOrchestratorHandler(client eth.LivepeerEthClien func (s *LivepeerServer) setOrchestratorConfigHandler(client eth.LivepeerEthClient) http.Handler { return mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.rejectIfDelegatedWallet(w, client) { + return + } + pixels := r.FormValue("pixelsPerUnit") price := r.FormValue("pricePerUnit") currency := r.FormValue("currency") @@ -1101,10 +1149,21 @@ func registeredOrchestratorsHandler(client eth.LivepeerEthClient, db *common.DB) }))) } -func rewardHandler(client eth.LivepeerEthClient) http.Handler { +func (s *LivepeerServer) rewardHandler(client eth.LivepeerEthClient) http.Handler { return mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - glog.Infof("Calling reward") - tx, err := client.Reward() + orch, delegated := s.delegatedOrchestrator(client) + + var ( + tx *ethtypes.Transaction + err error + ) + if delegated { + glog.Infof("Calling reward for orchestrator %v", orch.Hex()) + tx, err = client.RewardForTranscoder(orch) + } else { + glog.Infof("Calling reward") + tx, err = client.Reward() + } if err != nil { respond500(w, fmt.Sprintf("Error calling reward: %v", err)) return @@ -1118,6 +1177,66 @@ func rewardHandler(client eth.LivepeerEthClient) http.Handler { })) } +// setRewardCallerHandler authorizes an address to call reward on behalf of this node's +// account (LIP-118). An empty rewardCaller param unsets any existing authorization. +func (s *LivepeerServer) setRewardCallerHandler(client eth.LivepeerEthClient) http.Handler { + return mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The contract keys the mapping on msg.sender, so this only ever authorizes a + // caller for the wallet the node is running on. + if s.rejectIfDelegatedWallet(w, client) { + return + } + + var rewardCaller ethcommon.Address + if v := strings.TrimSpace(r.FormValue("rewardCaller")); v != "" { + if !ethcommon.IsHexAddress(v) { + respond400(w, fmt.Sprintf("invalid reward caller address %v", v)) + return + } + rewardCaller = ethcommon.HexToAddress(v) + } + + if rewardCaller == (ethcommon.Address{}) { + glog.Infof("Unsetting reward caller for %v", client.Account().Address.Hex()) + } else { + glog.Infof("Setting reward caller %v for %v", rewardCaller.Hex(), client.Account().Address.Hex()) + } + + tx, err := client.SetRewardCaller(rewardCaller) + if err != nil { + respond500(w, fmt.Sprintf("Error setting reward caller: %v", err)) + return + } + if err := client.CheckTx(tx); err != nil { + respond500(w, fmt.Sprintf("Error setting reward caller: %v", err)) + return + } + glog.Infof("Reward caller updated") + respondOk(w, nil) + })) +} + +// rewardCallerHandler returns the address currently authorized to call reward for the +// orchestrator this node acts for, or the empty string when none is set. +func (s *LivepeerServer) rewardCallerHandler(client eth.LivepeerEthClient) http.Handler { + return mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + orch, delegated := s.delegatedOrchestrator(client) + if !delegated { + orch = client.Account().Address + } + rewardCaller, err := client.GetRewardCaller(orch) + if err != nil { + respond500(w, fmt.Sprintf("Error getting reward caller: %v", err)) + return + } + if rewardCaller == (ethcommon.Address{}) { + respondOk(w, []byte("")) + return + } + respondOk(w, []byte(rewardCaller.Hex())) + })) +} + // Protocol parameters func protocolParametersHandler(client eth.LivepeerEthClient, db ChainIdGetter) http.Handler { return mustHaveDb(db, mustHaveClient(client, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/handlers_test.go b/server/handlers_test.go index 55c3aabca9..9b3415b5ea 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -25,6 +25,7 @@ import ( "github.com/livepeer/lpms/ffmpeg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) // Status @@ -1072,7 +1073,8 @@ func TestRewardHandler(t *testing.T) { assert := assert.New(t) client := ð.MockClient{} - handler := rewardHandler(client) + s := &LivepeerServer{} + handler := s.rewardHandler(client) client.On("Reward").Return(nil, nil) client.On("CheckTx", mock.Anything).Return(nil) @@ -1965,3 +1967,132 @@ func mockBigInt(args mock.Arguments) (*big.Int, error) { func trim(str []byte) string { return strings.TrimSpace(string(str)) } + +func TestSetRewardCallerHandler(t *testing.T) { + caller := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + + tests := []struct { + name string + form url.Values + wantStatus int + wantSet *ethcommon.Address + }{ + { + name: "sets the reward caller", + form: url.Values{"rewardCaller": {caller.Hex()}}, + wantStatus: http.StatusOK, + wantSet: &caller, + }, + { + name: "empty value unsets via the zero address", + form: url.Values{"rewardCaller": {""}}, + wantStatus: http.StatusOK, + wantSet: ðcommon.Address{}, + }, + { + name: "missing param unsets via the zero address", + form: url.Values{}, + wantStatus: http.StatusOK, + wantSet: ðcommon.Address{}, + }, + { + name: "rejects a malformed address", + form: url.Values{"rewardCaller": {"not-an-address"}}, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + client := ð.MockClient{} + s := &LivepeerServer{} + handler := s.setRewardCallerHandler(client) + + client.On("Account").Return(accounts.Account{}) + client.On("SetRewardCaller", mock.Anything).Return(nil, nil) + client.On("CheckTx", mock.Anything).Return(nil) + + status, _ := postForm(handler, tt.form) + assert.Equal(tt.wantStatus, status) + + if tt.wantSet != nil { + client.AssertCalled(t, "SetRewardCaller", *tt.wantSet) + } else { + client.AssertNumberOfCalls(t, "SetRewardCaller", 0) + } + }) + } +} + +// A node running on a delegated (reward caller) wallet must not send orchestrator +// configuration transactions. setServiceURI in particular has no on-chain caller check, +// so it would silently succeed against the wrong key. +func TestOrchestratorConfigHandlers_RejectDelegatedWallet(t *testing.T) { + account := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + orch := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + + newServer := func() *LivepeerServer { + n, err := core.NewLivepeerNode(nil, "", nil) + require.NoError(t, err) + n.RecipientAddr = orch.Hex() + return &LivepeerServer{LivepeerNode: n} + } + + tests := []struct { + name string + handler func(*LivepeerServer, eth.LivepeerEthClient) http.Handler + }{ + {"setOrchestratorConfig", func(s *LivepeerServer, c eth.LivepeerEthClient) http.Handler { + return s.setOrchestratorConfigHandler(c) + }}, + {"activateOrchestrator", func(s *LivepeerServer, c eth.LivepeerEthClient) http.Handler { + return s.activateOrchestratorHandler(c) + }}, + {"setRewardCaller", func(s *LivepeerServer, c eth.LivepeerEthClient) http.Handler { + return s.setRewardCallerHandler(c) + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + client := ð.MockClient{} + client.On("Account").Return(accounts.Account{Address: account}) + + status, body := postForm(tt.handler(newServer(), client), url.Values{}) + + assert.Equal(http.StatusBadRequest, status) + assert.Contains(body, orch.Hex()) + // No transaction of any kind may be sent from the delegated wallet. + client.AssertNotCalled(t, "SetServiceURI", mock.Anything) + client.AssertNotCalled(t, "Transcoder", mock.Anything, mock.Anything) + client.AssertNotCalled(t, "SetRewardCaller", mock.Anything) + }) + } +} + +// On a delegated wallet /reward must call rewardForTranscoder for the orchestrator rather +// than reward() for the node's own account, which is not a transcoder. +func TestRewardHandler_DelegatedWallet(t *testing.T) { + assert := assert.New(t) + account := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + orch := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + + n, err := core.NewLivepeerNode(nil, "", nil) + require.NoError(t, err) + n.RecipientAddr = orch.Hex() + + client := ð.MockClient{} + client.On("Account").Return(accounts.Account{Address: account}) + client.On("RewardForTranscoder", orch).Return(nil, nil) + client.On("Reward").Return(nil, nil) + client.On("CheckTx", mock.Anything).Return(nil) + + s := &LivepeerServer{LivepeerNode: n} + status, _ := post(s.rewardHandler(client)) + + assert.Equal(http.StatusOK, status) + client.AssertCalled(t, "RewardForTranscoder", orch) + client.AssertNumberOfCalls(t, "Reward", 0) +} diff --git a/server/webserver.go b/server/webserver.go index 65764b9b4e..c99a396ae8 100644 --- a/server/webserver.go +++ b/server/webserver.go @@ -80,7 +80,9 @@ func (s *LivepeerServer) cliWebServerHandlers(bindAddr string) *http.ServeMux { mux.Handle("/delegatorInfo", delegatorInfoHandler(client)) mux.Handle("/orchestratorEarningPoolsForRound", orchestratorEarningPoolsForRoundHandler(client)) mux.Handle("/registeredOrchestrators", registeredOrchestratorsHandler(client, db)) - mux.Handle("/reward", rewardHandler(client)) + mux.Handle("/reward", s.rewardHandler(client)) + mux.Handle("/setRewardCaller", mustHaveFormParams(s.setRewardCallerHandler(client))) + mux.Handle("/rewardCaller", s.rewardCallerHandler(client)) // Protocol parameters mux.Handle("/protocolParameters", protocolParametersHandler(client, db)) From 098a259cd595ead38f8e4ea3ef6685aae5e8aa9f Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 5 Aug 2026 13:49:00 +0200 Subject: [PATCH 2/2] chore: Add changelog entry for reward caller support Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG_PENDING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index f9afe08498..237fb4bcc4 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -12,6 +12,8 @@ #### Orchestrator +- [#4011](https://github.com/livepeer/go-livepeer/pull/4011) Add LIP-118 reward caller support, so a low-privilege wallet can call `reward()` on behalf of an orchestrator whose own wallet stays offline. Set one with the new "Set reward caller" action in `livepeer_cli`, then run the node with that wallet and `-ethOrchAddr ` (@rickstaa) + #### Transcoder ### Bug Fixes 🐞