diff --git a/go/consensus/tendermint/apps/roothash/roothash.go b/go/consensus/tendermint/apps/roothash/roothash.go index 48f71c632a9..e4af6f29371 100644 --- a/go/consensus/tendermint/apps/roothash/roothash.go +++ b/go/consensus/tendermint/apps/roothash/roothash.go @@ -9,6 +9,7 @@ import ( beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" "github.com/oasisprotocol/oasis-core/go/common" "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/logging" "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" tmapi "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/api" @@ -548,6 +549,58 @@ func (app *rootHashApplication) tryFinalizeExecutorCommits( return fmt.Errorf("failed to process runtime messages: %w", err) } + // If there was a discrepancy, slash nodes for incorrect results if configured. + if rtState.ExecutorPool.Discrepancy { + ctx.Logger().Debug("executor pool discrepancy", + "slashing", runtime.Staking.Slashing, + ) + if penalty, ok := rtState.Runtime.Staking.Slashing[staking.SlashRuntimeIncorrectResults]; ok && !penalty.Amount.IsZero() { + commitments := rtState.ExecutorPool.ExecuteCommitments + var ( + // Worker nodes that submitted incorrect results get slashed. + workersIncorrectResults []signature.PublicKey + // Backup worker nodes that resolved the discrepancy get rewarded. + backupCorrectResults []signature.PublicKey + ) + for _, n := range rtState.ExecutorPool.Committee.Members { + c, ok := commitments[n.PublicKey] + if !ok || c.IsIndicatingFailure() { + continue + } + switch n.Role { + case scheduler.RoleBackupWorker: + // Backup workers that resolved the discrepancy. + if !commit.MostlyEqual(c) { + continue + } + ctx.Logger().Debug("backup worker resolved the discrepancy", + "pubkey", n.PublicKey, + ) + backupCorrectResults = append(backupCorrectResults, n.PublicKey) + case scheduler.RoleWorker: + // Workers that caused the discrepancy. + if commit.MostlyEqual(c) { + continue + } + ctx.Logger().Debug("worker caused the discrepancy", + "pubkey", n.PublicKey, + ) + workersIncorrectResults = append(workersIncorrectResults, n.PublicKey) + } + } + // Slash for incorrect results. + if err = onRuntimeIncorrectResults( + ctx, + workersIncorrectResults, + backupCorrectResults, + runtime, + &penalty.Amount, + ); err != nil { + return fmt.Errorf("failed to slash for incorrect results: %w", err) + } + } + } + // Generate the final block. blk := block.NewEmptyBlock(rtState.CurrentBlock, uint64(ctx.Now().Unix()), block.Normal) blk.Header.IORoot = *hdr.IORoot diff --git a/go/consensus/tendermint/apps/roothash/slashing.go b/go/consensus/tendermint/apps/roothash/slashing.go index 8259e231881..3aa6376adce 100644 --- a/go/consensus/tendermint/apps/roothash/slashing.go +++ b/go/consensus/tendermint/apps/roothash/slashing.go @@ -9,6 +9,7 @@ import ( abciAPI "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/api" registryState "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/registry/state" stakingState "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/staking/state" + registry "github.com/oasisprotocol/oasis-core/go/registry/api" roothash "github.com/oasisprotocol/oasis-core/go/roothash/api" staking "github.com/oasisprotocol/oasis-core/go/staking/api" ) @@ -16,7 +17,7 @@ import ( func onEvidenceRuntimeEquivocation( ctx *abciAPI.Context, pk signature.PublicKey, - runtimeID common.Namespace, + runtime *registry.Runtime, penaltyAmount *quantity.Quantity, ) error { regState := registryState.NewMutableState(ctx.State()) @@ -33,7 +34,7 @@ func onEvidenceRuntimeEquivocation( return fmt.Errorf("tendermint/roothash: failed to get node by id %s: %w", pk, roothash.ErrInvalidEvidence) } - // Slash runtime node entity + // Slash runtime node entity. entityAddr := staking.NewAddress(node.EntityID) totalSlashed, err := stakeState.SlashEscrow(ctx, entityAddr, penaltyAmount) if err != nil { @@ -43,11 +44,155 @@ func onEvidenceRuntimeEquivocation( return fmt.Errorf("tendermint/roothash: nothing to slash from account %s", entityAddr) } - // Move slashed amount to the runtime account. - // TODO: part of slashed amount (configurable) should be transferred to the transaction submitter. + // If the caller is a node, distribute slashed funds to the controlling entity instead of the + // caller directly. + rewardAddr := ctx.CallerAddress() + callerNode, err := regState.Node(ctx, ctx.TxSigner()) + switch err { + case nil: + // Caller is a node, replace reward address with its controlling entity. + rewardAddr = staking.NewAddress(callerNode.EntityID) + case registry.ErrNoSuchNode: + // Not a node, reward the caller directly. + default: + return fmt.Errorf("tendermint/roothash: failed to lookup node: %w", err) + } + + // Distribute slashed funds to runtime and caller. + runtimePercentage := uint64(runtime.Staking.RewardSlashEquvocationRuntimePercent) + return distributeSlashedFunds(ctx, totalSlashed, runtimePercentage, runtime.ID, []staking.Address{rewardAddr}) +} + +func onRuntimeIncorrectResults( + ctx *abciAPI.Context, + discrepancyCausers []signature.PublicKey, + discrepancyResolvers []signature.PublicKey, + runtime *registry.Runtime, + penaltyAmount *quantity.Quantity, +) error { + regState := registryState.NewMutableState(ctx.State()) + stakeState := stakingState.NewMutableState(ctx.State()) + + var totalSlashed quantity.Quantity + for _, pk := range discrepancyCausers { + // Lookup the node entity. + node, err := regState.Node(ctx, pk) + if err == registry.ErrNoSuchNode { + ctx.Logger().Error("runtime node not found by commitment signature public key", + "public_key", pk, + ) + continue + } + if err != nil { + ctx.Logger().Error("failed to get runtime node by commitment signature public key", + "public_key", pk, + "err", err, + ) + return fmt.Errorf("tendermint/roothash: getting node %s: %w", pk, err) + } + entityAddr := staking.NewAddress(node.EntityID) + + // Slash entity. + slashed, err := stakeState.SlashEscrow(ctx, entityAddr, penaltyAmount) + if err != nil { + return fmt.Errorf("tendermint/roothash: error slashing account %s: %w", entityAddr, err) + } + if err = totalSlashed.Add(slashed); err != nil { + return fmt.Errorf("tendermint/roothash: totalSlashed.Add(slashed): %w", err) + } + ctx.Logger().Debug("runtime node entity slashed for incorrect results", + "slashed", slashed, + "total_slashed", totalSlashed, + "addr", entityAddr, + ) + } + + // It can happen that nothing was slashed as nodes could be out of stake. + // A node can be out of stake as stake claims are only checked on epoch transitions + // and a node can be slashed multiple times per epoch. + // This should not fail the round, as otherwise a single node without stake could + // cause round failures until it is removed from the committee (on the next epoch transition). + if totalSlashed.IsZero() { + // Nothing more to do in this case. + return nil + } + + // Determine who the backup workers' entities to reward are. + var rewardEntities []staking.Address + for _, pk := range discrepancyResolvers { + node, err := regState.Node(ctx, pk) + if err == registry.ErrNoSuchNode { + ctx.Logger().Error("runtime node not found by commitment signature public key", + "public_key", pk, + ) + continue + } + if err != nil { + ctx.Logger().Error("failed to get runtime node by commitment signature public key", + "public_key", pk, + "err", err, + ) + return fmt.Errorf("tendermint/roothash: getting node %s: %w", pk, err) + } + rewardEntities = append(rewardEntities, staking.NewAddress(node.EntityID)) + } + + // Distribute slashed funds to runtime and backup workers' entities. + runtimePercentage := uint64(runtime.Staking.RewardSlashBadResultsRuntimePercent) + return distributeSlashedFunds(ctx, &totalSlashed, runtimePercentage, runtime.ID, rewardEntities) +} + +func distributeSlashedFunds( + ctx *abciAPI.Context, + totalSlashed *quantity.Quantity, + runtimePercentage uint64, + runtimeID common.Namespace, + otherAddresses []staking.Address, +) error { + stakeState := stakingState.NewMutableState(ctx.State()) + + // Runtime account reward. + runtimeAccReward := totalSlashed.Clone() + if err := runtimeAccReward.Mul(quantity.NewFromUint64(runtimePercentage)); err != nil { + return fmt.Errorf("tendermint/roothash: runtimeAccReward.Mul: %w", err) + } + if err := runtimeAccReward.Quo(quantity.NewFromUint64(uint64(100))); err != nil { + return fmt.Errorf("tendermint/roothash: runtimeAccReward.Quo(100): %w", err) + } runtimeAddr := staking.NewRuntimeAddress(runtimeID) - if _, err := stakeState.TransferFromCommon(ctx, runtimeAddr, totalSlashed); err != nil { - return fmt.Errorf("tendermint/roothash: failed transferring reward to runtime account %s: %w", runtimeAddr, err) + if _, err := stakeState.TransferFromCommon(ctx, runtimeAddr, runtimeAccReward, false); err != nil { + return fmt.Errorf("tendermint/roothash: failed transferring reward to %s: %w", runtimeAddr, err) + } + ctx.Logger().Debug("runtime account awarded slashed funds", + "reward", runtimeAccReward, + "total_slashed", totalSlashed, + "runtime_addr", runtimeAddr, + ) + + if len(otherAddresses) == 0 { + // Nothing more to do. + ctx.Logger().Debug("no other accounts to reward") + return nil + } + + // (totalSlashed - runtimeAccReward) / n_reward_entities + otherReward := totalSlashed.Clone() + if err := otherReward.Sub(runtimeAccReward); err != nil { + return fmt.Errorf("tendermint/roothash: remainingReward.Sub(runtimeAccReward): %w", err) + } + if err := otherReward.Quo(quantity.NewFromUint64(uint64(len(otherAddresses)))); err != nil { + return fmt.Errorf("tendermint/roothash: remainingReward.Quo(len(discrepancyResolvers)): %w", err) + } + + for _, addr := range otherAddresses { + if _, err := stakeState.TransferFromCommon(ctx, addr, otherReward, true); err != nil { + return fmt.Errorf("tendermint/roothash: failed transferring reward to %s: %w", addr, err) + } + ctx.Logger().Debug("account awarded slashed funds", + "reward", otherReward, + "total_slashed", totalSlashed, + "address", addr, + ) } return nil diff --git a/go/consensus/tendermint/apps/roothash/slashing_test.go b/go/consensus/tendermint/apps/roothash/slashing_test.go index a07669cf1df..ed6c68c1aaa 100644 --- a/go/consensus/tendermint/apps/roothash/slashing_test.go +++ b/go/consensus/tendermint/apps/roothash/slashing_test.go @@ -1,6 +1,7 @@ package roothash import ( + "fmt" "testing" "time" @@ -26,20 +27,24 @@ func TestOnEvidenceRuntimeEquivocation(t *testing.T) { now := time.Unix(1580461674, 0) appState := abciAPI.NewMockApplicationState(&abciAPI.MockApplicationStateConfig{}) - ctx := appState.NewContext(abciAPI.ContextBeginBlock, now) + ctx := appState.NewContext(abciAPI.ContextDeliverTx, now) defer ctx.Close() regState := registryState.NewMutableState(ctx.State()) stakeState := stakingState.NewMutableState(ctx.State()) - runtime := registry.Runtime{} + runtime := ®istry.Runtime{ + Staking: registry.RuntimeStakingParameters{ + RewardSlashEquvocationRuntimePercent: 50, + }, + } testNodeSigner := memorySigner.NewTestSigner("runtime test signer") // Signer is not known as there are no nodes. err := onEvidenceRuntimeEquivocation( ctx, testNodeSigner.Public(), - runtime.ID, + runtime, amount, ) require.Error(err, "should fail when evidence signer address is not known") @@ -67,7 +72,7 @@ func TestOnEvidenceRuntimeEquivocation(t *testing.T) { err = onEvidenceRuntimeEquivocation( ctx, testNodeSigner.Public(), - runtime.ID, + runtime, amount, ) require.Error(err, "should fail when node has no stake") @@ -75,9 +80,9 @@ func TestOnEvidenceRuntimeEquivocation(t *testing.T) { // Give entity some stake. addr := staking.NewAddress(ent.ID) var balance quantity.Quantity - _ = balance.FromUint64(200) + _ = balance.FromUint64(300) var totalShares quantity.Quantity - _ = totalShares.FromUint64(200) + _ = totalShares.FromUint64(300) err = stakeState.SetAccount(ctx, addr, &staking.Account{ Escrow: staking.EscrowAccount{ Active: staking.SharePool{ @@ -92,7 +97,7 @@ func TestOnEvidenceRuntimeEquivocation(t *testing.T) { err = onEvidenceRuntimeEquivocation( ctx, testNodeSigner.Public(), - runtime.ID, + runtime, amount, ) require.NoError(err, "slashing should succeed") @@ -106,5 +111,184 @@ func TestOnEvidenceRuntimeEquivocation(t *testing.T) { // Runtime account should get the slashed amount. rtAcc, err := stakeState.Account(ctx, staking.NewRuntimeAddress(runtime.ID)) require.NoError(err, "runtime Addr") - require.EqualValues(amount, &rtAcc.General.Balance, "runtime account should get slashed amount") + + // Expected reward: (slashAmount / runtimeRewardPercentage). + expectedReward := amount.Clone() + runtimePercentage := quantity.NewFromUint64(uint64(runtime.Staking.RewardSlashEquvocationRuntimePercent)) + require.NoError(expectedReward.Mul(runtimePercentage)) + require.NoError(expectedReward.Quo(quantity.NewFromUint64(100))) + require.EqualValues(expectedReward, &rtAcc.General.Balance, "runtime account should get part of the slashed amount") + + // Transaction signer should get the rest (in escrow). + require.NoError(amount.Sub(expectedReward)) + callerAcc, err := stakeState.Account(ctx, ctx.CallerAddress()) + require.NoError(err, "caller Account") + require.EqualValues(amount, &callerAcc.Escrow.Active.Balance, "caller account should get the rest in escrow") + + // If the transaction signer is a node, the entity should get the rest. + expectedCallerReward := amount.Clone() + amount = quantity.NewFromUint64(100) + + ctx.SetTxSigner(testNodeSigner.Public()) + + // Should slash. + err = onEvidenceRuntimeEquivocation( + ctx, + testNodeSigner.Public(), + runtime, + amount, + ) + require.NoError(err, "slashing should succeed") + + // Entity stake should be slashed, but since the same entity got slashed and also provided + // evidence, the entity should be rewarded as well. + acct, err = stakeState.Account(ctx, addr) + require.NoError(err, "Account") + require.NoError(balance.Sub(amount)) // Slashed amount. + require.NoError(balance.Add(expectedCallerReward)) // Earned rewards. + require.EqualValues(balance, acct.Escrow.Active.Balance, "entity stake should be slashed") +} + +func TestOnRuntimeIncorrectResults(t *testing.T) { + require := require.New(t) + + amount := quantity.NewFromUint64(100) + + now := time.Unix(1580461674, 0) + appState := abciAPI.NewMockApplicationState(&abciAPI.MockApplicationStateConfig{}) + ctx := appState.NewContext(abciAPI.ContextBeginBlock, now) + defer ctx.Close() + + regState := registryState.NewMutableState(ctx.State()) + stakeState := stakingState.NewMutableState(ctx.State()) + + runtime := ®istry.Runtime{ + Staking: registry.RuntimeStakingParameters{ + RewardSlashBadResultsRuntimePercent: 50, + }, + } + missingNodeSigner := memorySigner.NewTestSigner("TestOnRuntimeIncorrectResults missing node signer") + + // Empty lists. + err := onRuntimeIncorrectResults( + ctx, + []signature.PublicKey{}, + []signature.PublicKey{}, + runtime, + amount, + ) + require.NoError(err, "should not fail when there's no nodes to be slashed") + + // Signer not known. + err = onRuntimeIncorrectResults( + ctx, + []signature.PublicKey{missingNodeSigner.Public()}, + []signature.PublicKey{}, + runtime, + amount, + ) + require.NoError(err, "should not fail when node to be slashed cannot be found") + + // Add state. + const numNodes, numSlashed = 20, 13 + var testNodes []*node.Node + for i := 0; i < numNodes; i++ { + // Add entity. + entitySigner := memorySigner.NewTestSigner(fmt.Sprintf("TestOnRuntimeIncorrectResults entity signer: %d", i)) + ent := &entity.Entity{ + ID: entitySigner.Public(), + } + var sigEntity *entity.SignedEntity + sigEntity, err = entity.SignEntity(entitySigner, registry.RegisterEntitySignatureContext, ent) + require.NoError(err, "SignEntity") + err = regState.SetEntity(ctx, ent, sigEntity) + require.NoError(err, "SetEntity") + // Add node. + nodeSigner := memorySigner.NewTestSigner(fmt.Sprintf("TestOnRuntimeIncorrectResults node signer: %d", i)) + nod := &node.Node{ + Versioned: cbor.NewVersioned(node.LatestNodeDescriptorVersion), + ID: nodeSigner.Public(), + EntityID: ent.ID, + Consensus: node.ConsensusInfo{}, + } + var sigNode *node.MultiSignedNode + sigNode, err = node.MultiSignNode([]signature.Signer{nodeSigner}, registry.RegisterNodeSignatureContext, nod) + require.NoError(err, "MultiSignNode") + err = regState.SetNode(ctx, nil, nod, sigNode) + require.NoError(err, "SetNode") + + testNodes = append(testNodes, nod) + } + + // Signers have no stake. + err = onRuntimeIncorrectResults( + ctx, + []signature.PublicKey{testNodes[0].ID}, + []signature.PublicKey{testNodes[1].ID}, + runtime, + amount, + ) + require.NoError(err, "should not fail when entity to be slashed has no stake") + + // Add stake. + initialEscrow := quantity.NewFromUint64(200) + for _, nod := range testNodes { + addr := staking.NewAddress(nod.EntityID) + var totalShares quantity.Quantity + _ = totalShares.FromUint64(200) + err = stakeState.SetAccount(ctx, addr, &staking.Account{ + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *initialEscrow, + TotalShares: totalShares, + }, + }, + }) + require.NoError(err, "SetAccount") + } + + // TODO: No reward nodes. + + // Multiple slash and reward nodes. + var toSlash, toReward []signature.PublicKey + for i, nod := range testNodes { + if i < numSlashed { + toSlash = append(toSlash, nod.ID) + } else { + toReward = append(toReward, nod.ID) + } + } + err = onRuntimeIncorrectResults( + ctx, + toSlash, + toReward, + runtime, + amount, + ) + require.NoError(err, "should not fail") + runtimePercentage := quantity.NewFromUint64(uint64(runtime.Staking.RewardSlashBadResultsRuntimePercent)) + // Ensure nodes were slash, and rewards were distributed. + for i, nod := range testNodes { + acc, err := stakeState.Account(ctx, staking.NewAddress(nod.EntityID)) + require.NoError(err, "stakeState.Account") + + expectedEscrow := initialEscrow.Clone() + switch i < numSlashed { + case true: + // Should be slashed. + require.NoError(expectedEscrow.Sub(amount), "expectedEscrow.Sub(slashAmount)") + require.EqualValues(*expectedEscrow, acc.Escrow.Active.Balance, "Expected amount should be slashed") + case false: + // Expected reward: ((slashAmount * n_slashed_nodes) / runtimeRewardPercentage) / n_reward_nodes. + expectedReward := amount.Clone() + require.NoError(expectedReward.Mul(quantity.NewFromUint64(numSlashed))) + require.NoError(expectedReward.Mul(runtimePercentage)) + require.NoError(expectedReward.Quo(quantity.NewFromUint64(100))) + require.NoError(expectedReward.Quo(quantity.NewFromUint64(numNodes - numSlashed))) + // Rewards are escrowed so they should add up to initial escrow. + require.NoError(expectedEscrow.Add(expectedReward)) + require.EqualValues(*expectedEscrow, acc.Escrow.Active.Balance, "Expected amount should be rewarded") + } + } + // Ensure runtime acc got the reward. } diff --git a/go/consensus/tendermint/apps/roothash/transactions.go b/go/consensus/tendermint/apps/roothash/transactions.go index e8a95caaa86..83b3c19212a 100644 --- a/go/consensus/tendermint/apps/roothash/transactions.go +++ b/go/consensus/tendermint/apps/roothash/transactions.go @@ -372,7 +372,7 @@ func (app *rootHashApplication) submitEvidence( if err = onEvidenceRuntimeEquivocation( ctx, pk, - rtState.Runtime.ID, + rtState.Runtime, &slash, ); err != nil { return fmt.Errorf("error slashing runtime node: %w", err) diff --git a/go/consensus/tendermint/apps/staking/state/state.go b/go/consensus/tendermint/apps/staking/state/state.go index 1d97d2edc7d..70f75d4aaa5 100644 --- a/go/consensus/tendermint/apps/staking/state/state.go +++ b/go/consensus/tendermint/apps/staking/state/state.go @@ -703,12 +703,14 @@ func (s *MutableState) TransferFromCommon( ctx *abciAPI.Context, toAddr staking.Address, amount *quantity.Quantity, + escrow bool, ) (bool, error) { commonPool, err := s.CommonPool(ctx) if err != nil { return false, fmt.Errorf("tendermint/staking: failed to query common pool for transfer: %w", err) } + // Transfer up to the given amount from the common pool to the general account balance. to, err := s.Account(ctx, toAddr) if err != nil { return false, fmt.Errorf("tendermint/staking: failed to query account %s: %w", toAddr, err) @@ -717,27 +719,55 @@ func (s *MutableState) TransferFromCommon( if err != nil { return false, fmt.Errorf("tendermint/staking: failed to transfer from common pool: %w", err) } + if transferred.IsZero() { + // Common pool has been depleated, nothing to transfer. + return false, nil + } + + // If escrow is requested, escrow the transferred stake immediately. + if escrow { + var delegation *staking.Delegation + delegation, err = s.Delegation(ctx, toAddr, toAddr) + if err != nil { + return false, fmt.Errorf("tendermint/staking: failed to query delegation: %w", err) + } - ret := !transferred.IsZero() - if ret { - if err = s.SetCommonPool(ctx, commonPool); err != nil { - return false, fmt.Errorf("tendermint/staking: failed to set common pool: %w", err) + if err = to.Escrow.Active.Deposit(&delegation.Shares, &to.General.Balance, transferred); err != nil { + return false, fmt.Errorf("tendermint/staking: failed to deposit to escrow: %w", err) } - if err = s.SetAccount(ctx, toAddr, to); err != nil { - return false, fmt.Errorf("tendermint/staking: failed to set account %s: %w", toAddr, err) + + if err = s.SetDelegation(ctx, toAddr, toAddr, delegation); err != nil { + return false, fmt.Errorf("tendermint/staking: failed to set delegation: %w", err) } + } - if !ctx.IsCheckOnly() { - ev := cbor.Marshal(&staking.TransferEvent{ - From: staking.CommonPoolAddress, - To: toAddr, + if err = s.SetCommonPool(ctx, commonPool); err != nil { + return false, fmt.Errorf("tendermint/staking: failed to set common pool: %w", err) + } + if err = s.SetAccount(ctx, toAddr, to); err != nil { + return false, fmt.Errorf("tendermint/staking: failed to set account %s: %w", toAddr, err) + } + + // Emit event(s). + if !ctx.IsCheckOnly() { + ev := cbor.Marshal(&staking.TransferEvent{ + From: staking.CommonPoolAddress, + To: toAddr, + Amount: *transferred, + }) + ctx.EmitEvent(api.NewEventBuilder(AppName).Attribute(KeyTransfer, ev)) + + if escrow { + ev = cbor.Marshal(&staking.AddEscrowEvent{ + Owner: toAddr, + Escrow: toAddr, Amount: *transferred, }) - ctx.EmitEvent(api.NewEventBuilder(AppName).Attribute(KeyTransfer, ev)) + ctx.EmitEvent(api.NewEventBuilder(AppName).Attribute(KeyAddEscrow, ev)) } } - return ret, nil + return true, nil } // TransferToGovernanceDeposits transfers the amount from the submitter to the diff --git a/go/oasis-node/cmd/registry/runtime/runtime.go b/go/oasis-node/cmd/registry/runtime/runtime.go index 22d4bb06ae2..626fbcf9fa5 100644 --- a/go/oasis-node/cmd/registry/runtime/runtime.go +++ b/go/oasis-node/cmd/registry/runtime/runtime.go @@ -82,6 +82,7 @@ const ( // Staking parameters flags. CfgStakingThreshold = "runtime.staking.threshold" + CfgStakingSlashing = "runtime.staking.slashing" // List runtimes flags. CfgIncludeSuspended = "include_suspended" @@ -454,7 +455,27 @@ func runtimeFromFlags() (*registry.Runtime, error) { // nolint: gocyclo rt.Staking.Thresholds[kind] = value } } + if sl := viper.GetStringMapString(CfgStakingSlashing); sl != nil { + rt.Staking.Slashing = make(map[staking.SlashReason]staking.Slash) + for reasonRaw, valueRaw := range sl { + var ( + reason staking.SlashReason + value quantity.Quantity + ) + if err = reason.UnmarshalText([]byte(reasonRaw)); err != nil { + return nil, fmt.Errorf("staking: bad slash raeson (%s): %w", reasonRaw, err) + } + if err = value.UnmarshalText([]byte(valueRaw)); err != nil { + return nil, fmt.Errorf("staking: bad slash value (%s): %w", valueRaw, err) + } + + if _, ok := rt.Staking.Slashing[reason]; ok { + return nil, fmt.Errorf("staking: duplicate value for reason '%s'", reason) + } + rt.Staking.Slashing[reason] = staking.Slash{Amount: value} + } + } // Validate descriptor. if err = rt.ValidateBasic(true); err != nil { return nil, fmt.Errorf("invalid runtime descriptor: %w", err) @@ -539,6 +560,7 @@ func init() { // Init Staking flags. runtimeFlags.StringToString(CfgStakingThreshold, nil, "Additional staking threshold for this runtime (=)") + runtimeFlags.StringToString(CfgStakingSlashing, nil, "Staking slashing parameter for this runtime (=)") _ = viper.BindPFlags(runtimeFlags) runtimeFlags.AddFlagSet(cmdSigner.Flags) diff --git a/go/oasis-test-runner/oasis/cli/registry.go b/go/oasis-test-runner/oasis/cli/registry.go index daf0e2cabbd..91b3351006c 100644 --- a/go/oasis-test-runner/oasis/cli/registry.go +++ b/go/oasis-test-runner/oasis/cli/registry.go @@ -106,6 +106,14 @@ func (r *RegistryHelpers) runRegistryRuntimeSubcommand( "--"+cmdRegRt.CfgStakingThreshold, fmt.Sprintf("%s=%s", string(kindRaw), string(valueRaw)), ) } + for reason, value := range runtime.Staking.Slashing { + reasonRaw, _ := reason.MarshalText() + valueRaw, _ := value.Amount.MarshalText() + + args = append(args, + "--"+cmdRegRt.CfgStakingSlashing, fmt.Sprintf("%s=%s", string(reasonRaw), string(valueRaw)), + ) + } if out, err := r.runSubCommandWithOutput("registry-runtime-"+cmd, args); err != nil { return fmt.Errorf("failed to run 'registry runtime %s': error: %w output: %s", cmd, err, out.String()) diff --git a/go/oasis-test-runner/scenario/e2e/runtime/byzantine.go b/go/oasis-test-runner/scenario/e2e/runtime/byzantine.go index 9e9918a316a..7272beef6e3 100644 --- a/go/oasis-test-runner/scenario/e2e/runtime/byzantine.go +++ b/go/oasis-test-runner/scenario/e2e/runtime/byzantine.go @@ -1,13 +1,20 @@ package runtime import ( + "context" + "fmt" "strconv" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" "github.com/oasisprotocol/oasis-core/go/oasis-node/cmd/debug/byzantine" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/env" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/log" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/scenario" + "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/scenario/e2e" + registry "github.com/oasisprotocol/oasis-core/go/registry/api" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" ) var ( @@ -31,6 +38,7 @@ var ( nil, oasis.ByzantineDefaultIdentitySeed, nil, + nil, ) // ByzantineExecutorSchedulerHonest is the byzantine executor scheduler honest scenario. ByzantineExecutorSchedulerHonest scenario.Scenario = newByzantineImpl( @@ -38,6 +46,7 @@ var ( "executor", nil, oasis.ByzantineSlot3IdentitySeed, + nil, []string{ "--" + byzantine.CfgSchedulerRoleExpected, }, @@ -53,6 +62,10 @@ var ( oasis.LogAssertExecutionDiscrepancyDetected(), }, oasis.ByzantineDefaultIdentitySeed, + // Byzantine node entity should be slashed once for submitting incorrect commitment. + map[staking.SlashReason]uint64{ + staking.SlashRuntimeIncorrectResults: 1, + }, []string{ "--" + byzantine.CfgExecutorMode, byzantine.ModeExecutorWrong.String(), }, @@ -69,6 +82,7 @@ var ( oasis.LogAssertExecutionDiscrepancyDetected(), }, oasis.ByzantineSlot3IdentitySeed, + nil, []string{ "--" + byzantine.CfgSchedulerRoleExpected, "--" + byzantine.CfgExecutorMode, byzantine.ModeExecutorWrong.String(), @@ -85,6 +99,7 @@ var ( oasis.LogAssertExecutionDiscrepancyDetected(), }, oasis.ByzantineDefaultIdentitySeed, + nil, []string{ "--" + byzantine.CfgExecutorMode, byzantine.ModeExecutorStraggler.String(), }, @@ -101,6 +116,7 @@ var ( oasis.LogAssertExecutionDiscrepancyDetected(), }, oasis.ByzantineSlot3IdentitySeed, + nil, []string{ "--" + byzantine.CfgSchedulerRoleExpected, "--" + byzantine.CfgExecutorMode, byzantine.ModeExecutorStraggler.String(), @@ -118,6 +134,7 @@ var ( oasis.LogAssertExecutionDiscrepancyDetected(), }, oasis.ByzantineDefaultIdentitySeed, + nil, []string{ "--" + byzantine.CfgExecutorMode, byzantine.ModeExecutorFailureIndicating.String(), }, @@ -134,6 +151,7 @@ var ( oasis.LogAssertExecutionDiscrepancyDetected(), }, oasis.ByzantineSlot3IdentitySeed, + nil, []string{ "--" + byzantine.CfgSchedulerRoleExpected, "--" + byzantine.CfgExecutorMode, byzantine.ModeExecutorFailureIndicating.String(), @@ -146,6 +164,7 @@ var ( nil, oasis.ByzantineDefaultIdentitySeed, nil, + nil, ) // ByzantineStorageFailApply is the byzantine storage scenario where storage node fails // first 5 Apply requests. @@ -156,6 +175,7 @@ var ( // should keep retrying proposing a batch until it succeeds. nil, oasis.ByzantineDefaultIdentitySeed, + nil, []string{ // Fail first 5 ApplyBatch requests. "--" + byzantine.CfgNumStorageFailApply, strconv.Itoa(5), @@ -173,6 +193,7 @@ var ( oasis.LogAssertRoundFailures(), }, oasis.ByzantineDefaultIdentitySeed, + nil, []string{ // Fail first 3 ApplyBatch requests - from the 2 executor workers and 1 backup node. "--" + byzantine.CfgNumStorageFailApplyBatch, strconv.Itoa(3), @@ -182,9 +203,10 @@ var ( ByzantineStorageFailRead scenario.Scenario = newByzantineImpl( "storage-fail-read", "storage", - // There should be no discrepancy or round failrues. + // There should be no discrepancy or round failures. nil, oasis.ByzantineDefaultIdentitySeed, + nil, []string{ // Fail all read requests. "--" + byzantine.CfgFailReadRequests, @@ -200,6 +222,11 @@ type byzantineImpl struct { identitySeed string logWatcherHandlerFactories []log.WatcherHandlerFactory + + // expectedSlashes are the expected slashes of the byzantine entity. Value + // is the number of times the entity is expected to be slashed for the specific + // reason. + expectedSlashes map[staking.SlashReason]uint64 } func newByzantineImpl( @@ -207,6 +234,7 @@ func newByzantineImpl( script string, logWatcherHandlerFactories []log.WatcherHandlerFactory, identitySeed string, + expectedSlashes map[staking.SlashReason]uint64, extraArgs []string, ) scenario.Scenario { return &byzantineImpl{ @@ -219,6 +247,7 @@ func newByzantineImpl( extraArgs: extraArgs, identitySeed: identitySeed, logWatcherHandlerFactories: logWatcherHandlerFactories, + expectedSlashes: expectedSlashes, } } @@ -229,6 +258,7 @@ func (sc *byzantineImpl) Clone() scenario.Scenario { extraArgs: sc.extraArgs, identitySeed: sc.identitySeed, logWatcherHandlerFactories: sc.logWatcherHandlerFactories, + expectedSlashes: sc.expectedSlashes, } } @@ -238,6 +268,39 @@ func (sc *byzantineImpl) Fixture() (*oasis.NetworkFixture, error) { return nil, err } + f.Runtimes[1].Staking = registry.RuntimeStakingParameters{ + Slashing: map[staking.SlashReason]staking.Slash{ + staking.SlashRuntimeIncorrectResults: { + Amount: *quantity.NewFromUint64(50), + }, + staking.SlashRuntimeEquivocation: { + Amount: *quantity.NewFromUint64(50), + }, + }, + } + f.Network.StakingGenesis = &staking.Genesis{ + TotalSupply: *quantity.NewFromUint64(100), + Ledger: map[staking.Address]*staking.Account{ + // Entity account needs escrow so that the byzantine node can get + // slashed. + e2e.DeterministicEntity1: { + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *quantity.NewFromUint64(100), + TotalShares: *quantity.NewFromUint64(100), + }, + }, + }, + }, + Delegations: map[staking.Address]map[staking.Address]*staking.Delegation{ + e2e.DeterministicEntity1: { + e2e.DeterministicEntity1: &staking.Delegation{ + Shares: *quantity.NewFromUint64(100), + }, + }, + }, + } + // The byzantine node requires deterministic identities. f.Network.DeterministicIdentities = true // The byzantine scenario requires mock epochtime as the byzantine node @@ -261,6 +324,8 @@ func (sc *byzantineImpl) Fixture() (*oasis.NetworkFixture, error) { } func (sc *byzantineImpl) Run(childEnv *env.Env) error { + ctx := context.Background() + clientErrCh, cmd, err := sc.runtimeImpl.start(childEnv) if err != nil { return err @@ -274,6 +339,30 @@ func (sc *byzantineImpl) Run(childEnv *env.Env) error { if err = sc.initialEpochTransitions(fixture); err != nil { return err } + err = sc.wait(childEnv, cmd, clientErrCh) + if err != nil { + return err + } + + // Ensure entity has expected stake. + acc, err := sc.Net.ClientController().Staking.Account(ctx, &staking.OwnerQuery{Height: consensus.HeightLatest, Owner: e2e.DeterministicEntity1}) + if err != nil { + return err + } + + // Calculate expected stake by going through expected slashes. + expectedStake := fixture.Network.StakingGenesis.Ledger[e2e.DeterministicEntity1].Escrow.Active.Balance.Clone() + for reason, times := range sc.expectedSlashes { + slashAmount := fixture.Runtimes[1].Staking.Slashing[reason].Amount + for i := uint64(0); i < times; i++ { + if err = expectedStake.Sub(&slashAmount); err != nil { + return fmt.Errorf("expectedStake.Sub(slashAmount): %w", err) + } + } + } + if expectedStake.Cmp(&acc.Escrow.Active.Balance) != 0 { + return fmt.Errorf("expected entity stake: %v got: %v", expectedStake, acc.General.Balance) + } - return sc.wait(childEnv, cmd, clientErrCh) + return nil } diff --git a/go/registry/api/runtime.go b/go/registry/api/runtime.go index c2112c79430..d465d83abb5 100644 --- a/go/registry/api/runtime.go +++ b/go/registry/api/runtime.go @@ -266,10 +266,24 @@ type RuntimeStakingParameters struct { // Slashing are the per-runtime misbehavior slashing parameters. Slashing map[staking.SlashReason]staking.Slash `json:"slashing,omitempty"` + + // RewardSlashEquvocationRuntimePercent is the percentage of the reward obtained when slashing + // for equivocation that is transferred to the runtime's account. + RewardSlashEquvocationRuntimePercent uint8 `json:"reward_equivocation,omitempty"` + + // RewardSlashBadResultsRuntimePercent is the percentage of the reward obtained when slashing + // for incorrect results that is transferred to the runtime's account. + RewardSlashBadResultsRuntimePercent uint8 `json:"reward_bad_results,omitempty"` } // ValidateBasic performs basic descriptor validity checks. func (s *RuntimeStakingParameters) ValidateBasic(runtimeKind RuntimeKind) error { + if s.RewardSlashEquvocationRuntimePercent > 100 { + return fmt.Errorf("runtime reward percentage from slashing for equivocation must be <= 100") + } + if s.RewardSlashBadResultsRuntimePercent > 100 { + return fmt.Errorf("runtime reward percentage from slashing for bad results must be <= 100") + } for kind, q := range s.Thresholds { switch kind { case staking.KindNodeCompute, staking.KindNodeStorage: diff --git a/go/registry/tests/tester.go b/go/registry/tests/tester.go index e12fcc7b2c3..efd25baed19 100644 --- a/go/registry/tests/tester.go +++ b/go/registry/tests/tester.go @@ -1739,6 +1739,7 @@ func NewTestRuntime(seed []byte, ent *TestEntity, isKeyManager bool) (*TestRunti Amount: *quantity.NewFromUint64(math.MaxInt64), }, }, + RewardSlashEquvocationRuntimePercent: 100, }, } // TODO: Test with non-empty state root when enabled. diff --git a/go/roothash/api/commitment/pool.go b/go/roothash/api/commitment/pool.go index 11e45ff05f8..642fea429c6 100644 --- a/go/roothash/api/commitment/pool.go +++ b/go/roothash/api/commitment/pool.go @@ -263,6 +263,8 @@ func (p *Pool) addOpenExecutorCommitment( return ErrTxnSchedSigInvalid } + // TODO: check for evidence of equivocation. + switch openCom.IsIndicatingFailure() { case true: default: