diff --git a/.changelog/3640.breaking.md b/.changelog/3640.breaking.md new file mode 100644 index 00000000000..38260227a9c --- /dev/null +++ b/.changelog/3640.breaking.md @@ -0,0 +1,4 @@ +go/roothash: Support slashing runtime compute nodes + +Runtimes can configure whether compute nodes should be slashed for submitting +incorrect results or equivocating. diff --git a/docs/adr/0005-runtime-compute-slashing.md b/docs/adr/0005-runtime-compute-slashing.md index b2f57ef7826..cdb6544eaf1 100644 --- a/docs/adr/0005-runtime-compute-slashing.md +++ b/docs/adr/0005-runtime-compute-slashing.md @@ -50,6 +50,14 @@ 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"` } ``` @@ -175,13 +183,13 @@ Any slashing instructions related to freezing nodes are currently ignored. This proposal introduces/updates the following consensus state in the roothash module: -- **List of past valid evidence (`0x23`)** +- **List of past valid evidence (`0x24`)** A hash uniquely identifying the evidence is stored for each successfully processed evidence that has not yet expired using the following key format: ``` - 0x23 + 0x24 ``` The value is empty as we only need to detect duplicate evidence. @@ -293,7 +301,7 @@ performed to verify evidence validity: - Public keys of signers of both commitments are compared. If they are not the same, the evidence is invalid. -- Signatures of both proposed batches are compared. If either is invalid, the +- Signatures of both proposed batches are validated. If either is invalid, the evidence is invalid. - Otherwise the evidence is valid. diff --git a/go/consensus/tendermint/apps/roothash/roothash.go b/go/consensus/tendermint/apps/roothash/roothash.go index d20f0e367c6..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" @@ -132,6 +133,20 @@ func (app *rootHashApplication) onCommitteeChanged(ctx *tmapi.Context, state *ro return fmt.Errorf("failed to fetch runtime state: %w", err) } + // Expire past evidence of runtime node misbehaviour. + if rtState.CurrentBlock != nil { + if round := rtState.CurrentBlock.Header.Round; round > params.MaxEvidenceAge { + ctx.Logger().Debug("removing expired runtime evidence", + "runtime", rt.ID, + "round", round, + "max_evidence_age", params.MaxEvidenceAge, + ) + if err = state.RemoveExpiredEvidence(ctx, rt.ID, round-params.MaxEvidenceAge); err != nil { + return fmt.Errorf("failed to remove expired runtime evidence: %s %w", rt.ID, err) + } + } + } + // Since the runtime is in the list of active runtimes in the registry we // can safely clear the suspended flag. rtState.Suspended = false @@ -359,6 +374,13 @@ func (app *rootHashApplication) ExecuteTx(ctx *tmapi.Context, tx *transaction.Tr } return app.executorProposerTimeout(ctx, state, &xc) + case roothash.MethodEvidence: + var ev roothash.Evidence + if err := cbor.Unmarshal(tx.Body, &ev); err != nil { + return err + } + + return app.submitEvidence(ctx, state, &ev) default: return roothash.ErrInvalidArgument } @@ -527,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 new file mode 100644 index 00000000000..1098714159e --- /dev/null +++ b/go/consensus/tendermint/apps/roothash/slashing.go @@ -0,0 +1,204 @@ +package roothash + +import ( + "fmt" + + "github.com/oasisprotocol/oasis-core/go/common" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + 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" +) + +func onEvidenceRuntimeEquivocation( + ctx *abciAPI.Context, + pk signature.PublicKey, + runtime *registry.Runtime, + penaltyAmount *quantity.Quantity, +) error { + regState := registryState.NewMutableState(ctx.State()) + stakeState := stakingState.NewMutableState(ctx.State()) + + node, err := regState.Node(ctx, pk) + if err != nil { + // Node might not exist anymore (old evidence). Or the submitted evidence + // could be for a non-existing node (submitting "fake" but valid evidence). + ctx.Logger().Error("failed to get runtime node by signature public key", + "public_key", pk, + "err", err, + ) + return fmt.Errorf("tendermint/roothash: failed to get node by id %s: %w", pk, roothash.ErrInvalidEvidence) + } + + // Slash runtime node entity. + entityAddr := staking.NewAddress(node.EntityID) + totalSlashed, err := stakeState.SlashEscrow(ctx, entityAddr, penaltyAmount) + if err != nil { + return fmt.Errorf("tendermint/roothash: error slashing account %s: %w", entityAddr, err) + } + // Since evidence can be submitted for past rounds, the node can be out of stake. + if totalSlashed.IsZero() { + ctx.Logger().Warn("nothing to slash from entity for runtime equivocation", + "penalty", penaltyAmount, + "addr", entityAddr, + ) + return nil + } + + // 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, 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 new file mode 100644 index 00000000000..05669fd21de --- /dev/null +++ b/go/consensus/tendermint/apps/roothash/slashing_test.go @@ -0,0 +1,305 @@ +package roothash + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + memorySigner "github.com/oasisprotocol/oasis-core/go/common/crypto/signature/signers/memory" + "github.com/oasisprotocol/oasis-core/go/common/entity" + "github.com/oasisprotocol/oasis-core/go/common/node" + "github.com/oasisprotocol/oasis-core/go/common/quantity" + 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" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" +) + +func TestOnEvidenceRuntimeEquivocation(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.ContextDeliverTx, now) + defer ctx.Close() + + regState := registryState.NewMutableState(ctx.State()) + stakeState := stakingState.NewMutableState(ctx.State()) + + 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, + amount, + ) + require.Error(err, "should fail when evidence signer address is not known") + + // Add entity. + ent, entitySigner, _ := entity.TestEntity() + 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("node test signer") + nod := &node.Node{ + Versioned: cbor.NewVersioned(node.LatestNodeDescriptorVersion), + ID: testNodeSigner.Public(), + EntityID: ent.ID, + Consensus: node.ConsensusInfo{}, + } + 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") + + // Should not fail if the entity has no stake. + err = onEvidenceRuntimeEquivocation( + ctx, + testNodeSigner.Public(), + runtime, + amount, + ) + require.NoError(err, "should not fail when entity has no stake") + + // Give entity some stake. + addr := staking.NewAddress(ent.ID) + var balance quantity.Quantity + _ = balance.FromUint64(300) + var totalShares quantity.Quantity + _ = totalShares.FromUint64(300) + err = stakeState.SetAccount(ctx, addr, &staking.Account{ + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: balance, + TotalShares: totalShares, + }, + CommissionSchedule: staking.CommissionSchedule{ + Rates: []staking.CommissionRateStep{{ + Start: 0, + Rate: *quantity.NewFromUint64(20_000), // 20% + }}, + Bounds: []staking.CommissionRateBoundStep{{ + Start: 0, + RateMin: *quantity.NewFromUint64(0), + RateMax: *quantity.NewFromUint64(100_000), // 100% + }}, + }, + }, + }) + require.NoError(err, "SetAccount") + + // Should slash. + err = onEvidenceRuntimeEquivocation( + ctx, + testNodeSigner.Public(), + runtime, + amount, + ) + require.NoError(err, "slashing should succeed") + + // Entity stake should be slashed. + acct, err := stakeState.Account(ctx, addr) + require.NoError(err, "Account") + require.NoError(balance.Sub(amount)) + require.EqualValues(balance, acct.Escrow.Active.Balance, "entity stake should be slashed") + + // Runtime account should get the slashed amount. + rtAcc, err := stakeState.Account(ctx, staking.NewRuntimeAddress(runtime.ID)) + require.NoError(err, "runtime Addr") + + // 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/state/state.go b/go/consensus/tendermint/apps/roothash/state/state.go index 1235979f209..5beb63111cd 100644 --- a/go/consensus/tendermint/apps/roothash/state/state.go +++ b/go/consensus/tendermint/apps/roothash/state/state.go @@ -6,6 +6,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common" "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" "github.com/oasisprotocol/oasis-core/go/common/keyformat" "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/api" roothash "github.com/oasisprotocol/oasis-core/go/roothash/api" @@ -29,6 +30,10 @@ var ( // // Value is a CBOR-serialized `true`. rejectTransactionsKeyFmt = keyformat.New(0x23) + // evidenceKeyFmt is the key format used for storing valid misbehaviour evidence. + // + // Key format is: 0x24 + evidenceKeyFmt = keyformat.New(0x24, keyformat.H(&common.Namespace{}), uint64(0), &hash.Hash{}) cborTrue = cbor.Marshal(true) ) @@ -164,6 +169,12 @@ func (s *ImmutableState) RejectTransactions(ctx context.Context) (bool, error) { return true, nil } +// EvidenceHashExists returns true if the evidence hash for the runtime exists. +func (s *ImmutableState) EvidenceHashExists(ctx context.Context, runtimeID common.Namespace, round uint64, hash hash.Hash) (bool, error) { + data, err := s.is.Get(ctx, evidenceKeyFmt.Encode(&runtimeID, round, &hash)) + return data != nil, api.UnavailableStateError(err) +} + // MutableState is the mutable roothash state wrapper. type MutableState struct { *ImmutableState @@ -216,3 +227,37 @@ func (s *MutableState) ClearRejectTransactions(ctx context.Context) error { err := s.ms.Remove(ctx, rejectTransactionsKeyFmt.Encode()) return api.UnavailableStateError(err) } + +// SetEvidenceHash sets the provided evidence hash. +func (s *MutableState) SetEvidenceHash(ctx context.Context, runtimeID common.Namespace, round uint64, hash hash.Hash) error { + err := s.ms.Insert(ctx, evidenceKeyFmt.Encode(&runtimeID, round, &hash), []byte("")) + return api.UnavailableStateError(err) +} + +// RemoveExpiredEvidence removes expired evidence. +func (s *MutableState) RemoveExpiredEvidence(ctx context.Context, runtimeID common.Namespace, minRound uint64) error { + it := s.is.NewIterator(ctx) + defer it.Close() + + var toDelete [][]byte + for it.Seek(evidenceKeyFmt.Encode(&runtimeID)); it.Valid(); it.Next() { + var runtimeID keyformat.PreHashed + var round uint64 + var hash hash.Hash + if !evidenceKeyFmt.Decode(it.Key(), &runtimeID, &round, &hash) { + break + } + if round > minRound { + break + } + toDelete = append(toDelete, it.Key()) + } + + for _, key := range toDelete { + if err := s.ms.Remove(ctx, key); err != nil { + return api.UnavailableStateError(err) + } + } + + return nil +} diff --git a/go/consensus/tendermint/apps/roothash/state/state_test.go b/go/consensus/tendermint/apps/roothash/state/state_test.go new file mode 100644 index 00000000000..3525bb8b569 --- /dev/null +++ b/go/consensus/tendermint/apps/roothash/state/state_test.go @@ -0,0 +1,113 @@ +package state + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/oasisprotocol/oasis-core/go/common" + abciAPI "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/api" + "github.com/oasisprotocol/oasis-core/go/roothash/api" +) + +func TestEvidence(t *testing.T) { + require := require.New(t) + + now := time.Unix(1580461674, 0) + appState := abciAPI.NewMockApplicationState(&abciAPI.MockApplicationStateConfig{}) + ctx := appState.NewContext(abciAPI.ContextBeginBlock, now) + defer ctx.Close() + + s := NewMutableState(ctx.State()) + + rt1ID := common.NewTestNamespaceFromSeed([]byte("apps/roothash/state_test: runtime1"), 0) + rt2ID := common.NewTestNamespaceFromSeed([]byte("apps/roothash/state_test: runtime2"), 0) + rt3ID := common.NewTestNamespaceFromSeed([]byte("apps/roothash/state_test: runtime3"), 0) + + for _, ev := range []struct { + ns common.Namespace + r uint64 + ev api.Evidence + }{ + { + rt1ID, + 0, + api.Evidence{ + EquivocationExecutor: &api.EquivocationExecutorEvidence{}, + }, + }, + { + rt1ID, + 10, + api.Evidence{ + EquivocationBatch: &api.EquivocationBatchEvidence{}, + }, + }, + { + rt1ID, + 20, + api.Evidence{ + EquivocationExecutor: &api.EquivocationExecutorEvidence{}, + }, + }, + { + rt2ID, + 5, + api.Evidence{ + EquivocationExecutor: &api.EquivocationExecutorEvidence{}, + }, + }, + { + rt2ID, + 10, + api.Evidence{ + EquivocationBatch: &api.EquivocationBatchEvidence{}, + }, + }, + { + rt2ID, + 20, + api.Evidence{ + EquivocationExecutor: &api.EquivocationExecutorEvidence{}, + }, + }, + } { + h, err := ev.ev.Hash() + require.NoError(err, "ev.Hash()", ev.ev) + err = s.SetEvidenceHash(ctx, ev.ns, ev.r, h) + require.NoError(err, "SetEvidenceHash()", ev) + b, err := s.EvidenceHashExists(ctx, ev.ns, ev.r, h) + require.NoError(err, "EvidenceHashExists", ev) + require.True(b, "EvidenceHashExists", ev) + } + + ev := api.Evidence{ + EquivocationExecutor: &api.EquivocationExecutorEvidence{}, + } + h, err := ev.Hash() + require.NoError(err, "ev.Hash()") + b, err := s.EvidenceHashExists(ctx, rt1ID, 5, h) + require.NoError(err, "EvidenceHashExists") + require.False(b, "Evidence hash should not exist") + + b, err = s.EvidenceHashExists(ctx, rt2ID, 5, h) + require.NoError(err, "EvidenceHashExists") + require.True(b, "Evidence hash should exist") + + // Expire evidence. + err = s.RemoveExpiredEvidence(ctx, rt1ID, 10) + require.NoError(err, "RemoveExpiredEvidence") + err = s.RemoveExpiredEvidence(ctx, rt2ID, 10) + require.NoError(err, "RemoveExpiredEvidence") + err = s.RemoveExpiredEvidence(ctx, rt3ID, 1) + require.NoError(err, "RemoveExpiredEvidence") + + b, err = s.EvidenceHashExists(ctx, rt2ID, 5, h) + require.NoError(err, "EvidenceHashExists") + require.False(b, "Expired evidence hash should not exist anymore") + + b, err = s.EvidenceHashExists(ctx, rt1ID, 20, h) + require.NoError(err, "EvidenceHashExists") + require.True(b, "Not expired evidence hash should still exist") +} diff --git a/go/consensus/tendermint/apps/roothash/transactions.go b/go/consensus/tendermint/apps/roothash/transactions.go index 6e6297970d7..60e99508cf3 100644 --- a/go/consensus/tendermint/apps/roothash/transactions.go +++ b/go/consensus/tendermint/apps/roothash/transactions.go @@ -17,6 +17,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/roothash/api/commitment" "github.com/oasisprotocol/oasis-core/go/roothash/api/message" scheduler "github.com/oasisprotocol/oasis-core/go/scheduler/api" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" ) var _ commitment.SignatureVerifier = (*roothashSignatureVerifier)(nil) @@ -135,6 +136,11 @@ func (app *rootHashApplication) executorProposerTimeout( return err } + // Return early for simulation as we only need gas accounting. + if ctx.IsSimulation() { + return nil + } + rtState, sv, nl, err := app.getRuntimeState(ctx, state, rpt.ID) if err != nil { return err @@ -267,3 +273,123 @@ func (app *rootHashApplication) executorCommit( return nil } + +func (app *rootHashApplication) submitEvidence( + ctx *abciAPI.Context, + state *roothashState.MutableState, + evidence *roothash.Evidence, +) error { + // Validate proposal content basics. + if err := evidence.ValidateBasic(); err != nil { + ctx.Logger().Error("Evidence: submitted evidence not valid", + "evidence", evidence, + "err", err, + ) + return fmt.Errorf("%w: %v", roothash.ErrInvalidEvidence, err) + } + + if ctx.IsCheckOnly() { + return nil + } + + // Charge gas for this transaction. + params, err := state.ConsensusParameters(ctx) + if err != nil { + ctx.Logger().Error("Evidence: failed to fetch consensus parameters", + "err", err, + ) + return err + } + if err = ctx.Gas().UseGas(1, roothash.GasOpEvidence, params.GasCosts); err != nil { + return err + } + + // Return early for simulation as we only need gas accounting. + if ctx.IsSimulation() { + return nil + } + + rtState, _, _, err := app.getRuntimeState(ctx, state, evidence.ID) + if err != nil { + return err + } + + if len(rtState.Runtime.Staking.Slashing) == 0 { + // No slashing instructions for runtime, no point in collecting evidence. + ctx.Logger().Error("Evidence: runtime has no slashing instructions", + "err", roothash.ErrRuntimeDoesNotSlash, + ) + return roothash.ErrRuntimeDoesNotSlash + } + slash := rtState.Runtime.Staking.Slashing[staking.SlashRuntimeEquivocation].Amount + if slash.IsZero() { + // Slash amount is zero for runtime, no point in collecting evidence. + ctx.Logger().Error("Evidence: runtime has no slashing instructions for equivocation", + "err", roothash.ErrRuntimeDoesNotSlash, + ) + return roothash.ErrRuntimeDoesNotSlash + } + + // Ensure evidence is not expired. + var round uint64 + var pk signature.PublicKey + switch { + case evidence.EquivocationExecutor != nil: + commitA, _ := evidence.EquivocationExecutor.CommitA.Open() + + if commitA.Body.Header.Round+params.MaxEvidenceAge < rtState.CurrentBlock.Header.Round { + ctx.Logger().Error("Evidence: commitment equivocation evidence expired", + "evidence", evidence.EquivocationExecutor, + "current_round", rtState.CurrentBlock.Header.Round, + "max_evidence_age", params.MaxEvidenceAge, + ) + return fmt.Errorf("%w: equivocation evidence expired", roothash.ErrInvalidEvidence) + } + round = commitA.Body.Header.Round + pk = commitA.Signature.PublicKey + case evidence.EquivocationBatch != nil: + var batchA commitment.ProposedBatch + _ = evidence.EquivocationBatch.BatchA.Open(&batchA) + + if batchA.Header.Round+params.MaxEvidenceAge < rtState.CurrentBlock.Header.Round { + ctx.Logger().Error("Evidence: proposed batch equivocation evidence expired", + "evidence", evidence.EquivocationExecutor, + "current_round", rtState.CurrentBlock.Header.Round, + "max_evidence_age", params.MaxEvidenceAge, + ) + return fmt.Errorf("%w: equivocation evidence expired", roothash.ErrInvalidEvidence) + } + round = batchA.Header.Round + pk = evidence.EquivocationBatch.BatchA.Signature.PublicKey + default: + // This should never happen due to ValidateBasic check above. + return roothash.ErrInvalidEvidence + } + + // Evidence is valid. Store the evidence and slash the node. + evHash, err := evidence.Hash() + if err != nil { + return fmt.Errorf("error computing evidence hash: %w", err) + } + b, err := state.ImmutableState.EvidenceHashExists(ctx, rtState.Runtime.ID, round, evHash) + if err != nil { + return fmt.Errorf("error querying evidence hash: %w", err) + } + if b { + return roothash.ErrDuplicateEvidence + } + if err = state.SetEvidenceHash(ctx, rtState.Runtime.ID, round, evHash); err != nil { + return err + } + + if err = onEvidenceRuntimeEquivocation( + ctx, + pk, + rtState.Runtime, + &slash, + ); err != nil { + return fmt.Errorf("error slashing runtime node: %w", err) + } + + return nil +} diff --git a/go/consensus/tendermint/apps/roothash/transactions_test.go b/go/consensus/tendermint/apps/roothash/transactions_test.go index 18d5141971f..a7608c4594c 100644 --- a/go/consensus/tendermint/apps/roothash/transactions_test.go +++ b/go/consensus/tendermint/apps/roothash/transactions_test.go @@ -8,14 +8,20 @@ import ( "github.com/stretchr/testify/require" + "github.com/oasisprotocol/oasis-core/go/common" + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" memorySigner "github.com/oasisprotocol/oasis-core/go/common/crypto/signature/signers/memory" + "github.com/oasisprotocol/oasis-core/go/common/node" + "github.com/oasisprotocol/oasis-core/go/common/quantity" "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" abciAPI "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/api" registryState "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/registry/state" roothashApi "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/roothash/api" roothashState "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/roothash/state" schedulerState "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/scheduler/state" + stakingState "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/staking/state" genesisTestHelpers "github.com/oasisprotocol/oasis-core/go/genesis/tests" registry "github.com/oasisprotocol/oasis-core/go/registry/api" roothash "github.com/oasisprotocol/oasis-core/go/roothash/api" @@ -205,3 +211,355 @@ func TestMessagesGasEstimation(t *testing.T) { require.NoError(err, "ExecutorCommit") require.EqualValues(5000, ctx.Gas().GasUsed(), "gas amount should be correct") } + +func TestEvidence(t *testing.T) { + require := require.New(t) + var err error + + genesisTestHelpers.SetTestChainContext() + + now := time.Unix(1580461674, 0) + appState := abciAPI.NewMockApplicationState(&abciAPI.MockApplicationStateConfig{}) + ctx := appState.NewContext(abciAPI.ContextDeliverTx, now) + defer ctx.Close() + + // Generate a private key for the node in this test. + sk, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + + // Signer for a non-existing node. + nonExistingSigner, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + entitySigner := memorySigner.NewTestSigner("consensus/tendermint/apps/roothash: entity signer") + + // Initialize staking state. + stakingState := stakingState.NewMutableState(ctx.State()) + err = stakingState.SetConsensusParameters(ctx, &staking.ConsensusParameters{}) + require.NoError(err, "staking.SetConsensusParameters") + entityEscrow := quantity.NewFromUint64(100) + entityAccount := staking.Account{ + General: staking.GeneralAccount{ + Balance: quantity.Quantity{}, + }, + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *entityEscrow, + TotalShares: *quantity.NewFromUint64(100), + }, + }, + } + err = stakingState.SetAccount(ctx, staking.NewAddress(entitySigner.Public()), &entityAccount) + require.NoError(err, "SetAccount") + + // Initialize registry state. + registryState := registryState.NewMutableState(ctx.State()) + + nod := &node.Node{ + Versioned: cbor.NewVersioned(node.LatestNodeDescriptorVersion), + ID: sk.Public(), + Consensus: node.ConsensusInfo{ID: sk.Public()}, + EntityID: entitySigner.Public(), + } + sigNode, nErr := node.MultiSignNode([]signature.Signer{sk}, registry.RegisterNodeSignatureContext, nod) + require.NoError(nErr, "MultiSignNode") + err = registryState.SetNode(ctx, nil, nod, sigNode) + require.NoError(err, "SetNode") + + // Initialize runtimes. + slashAmount := quantity.NewFromUint64(40) + runtime := registry.Runtime{ + Executor: registry.ExecutorParameters{ + MaxMessages: 32, + }, + + Staking: registry.RuntimeStakingParameters{ + Slashing: map[staking.SlashReason]staking.Slash{ + staking.SlashRuntimeEquivocation: {Amount: *slashAmount}, + }, + }, + } + runtimeNoSlashing := registry.Runtime{ + ID: common.NewTestNamespaceFromSeed([]byte("tendermint/apps/roothash/transaction_test: runtime no slashing"), 0), + } + runtimeZeroSlashing := registry.Runtime{ + ID: common.NewTestNamespaceFromSeed([]byte("tendermint/apps/roothash/transaction_test: runtime zero slashing"), 0), + Staking: registry.RuntimeStakingParameters{ + Slashing: map[staking.SlashReason]staking.Slash{ + staking.SlashRuntimeEquivocation: {}, + }, + }, + } + + // Initialize scheduler state. + schedulerState := schedulerState.NewMutableState(ctx.State()) + executorCommittee := scheduler.Committee{ + RuntimeID: runtime.ID, + Kind: scheduler.KindComputeExecutor, + Members: []*scheduler.CommitteeNode{ + { + Role: scheduler.RoleWorker, + PublicKey: sk.Public(), + }, + }, + } + err = schedulerState.PutCommittee(ctx, &executorCommittee) + require.NoError(err, "PutCommittee") + storageCommittee := scheduler.Committee{ + RuntimeID: runtime.ID, + Kind: scheduler.KindStorage, + Members: []*scheduler.CommitteeNode{ + { + Role: scheduler.RoleWorker, + PublicKey: sk.Public(), + }, + }, + } + err = schedulerState.PutCommittee(ctx, &storageCommittee) + require.NoError(err, "PutCommittee") + + // Initialize roothash state. + roothashState := roothashState.NewMutableState(ctx.State()) + err = roothashState.SetConsensusParameters(ctx, &roothash.ConsensusParameters{ + MaxRuntimeMessages: 32, + MaxEvidenceAge: 50, + }) + require.NoError(err, "SetConsensusParameters") + blk := block.NewGenesisBlock(runtime.ID, 0) + blk.Header.Round = 99 + err = roothashState.SetRuntimeState(ctx, &roothash.RuntimeState{ + Runtime: &runtime, + GenesisBlock: blk, + CurrentBlock: blk, + CurrentBlockHeight: 1000, + LastNormalRound: 99, + LastNormalHeight: 1000, + ExecutorPool: &commitment.Pool{ + Runtime: &runtime, + Committee: &executorCommittee, + Round: 99, + }, + }) + require.NoError(err, "SetRuntimeState") + err = roothashState.SetRuntimeState(ctx, &roothash.RuntimeState{ + Runtime: &runtimeNoSlashing, + GenesisBlock: blk, + CurrentBlock: blk, + CurrentBlockHeight: 1000, + LastNormalRound: 99, + LastNormalHeight: 1000, + ExecutorPool: &commitment.Pool{ + Runtime: &runtime, + Committee: &executorCommittee, + Round: 99, + }, + }) + require.NoError(err, "SetRuntimeState") + err = roothashState.SetRuntimeState(ctx, &roothash.RuntimeState{ + Runtime: &runtimeZeroSlashing, + GenesisBlock: blk, + CurrentBlock: blk, + CurrentBlockHeight: 1000, + LastNormalRound: 99, + LastNormalHeight: 1000, + ExecutorPool: &commitment.Pool{ + Runtime: &runtime, + Committee: &executorCommittee, + Round: 99, + }, + }) + require.NoError(err, "SetRuntimeState") + + // Initialize evidence. + blk2 := block.NewEmptyBlock(blk, 0, block.Normal) + + // Proposed batch. + batch1 := &commitment.ProposedBatch{ + IORoot: blk2.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: blk2.Header, + } + signedBatch1, err := commitment.SignProposedBatch(sk, batch1) + require.NoError(err, "SignProposedBatch") + nonExistingSignerBatch1, err := commitment.SignProposedBatch(nonExistingSigner, batch1) + require.NoError(err, "SignProposedBatch") + + batch2 := &commitment.ProposedBatch{ + IORoot: hash.NewFromBytes([]byte("invalid root")), + StorageSignatures: []signature.Signature{}, + Header: blk2.Header, + } + signedBatch2, err := commitment.SignProposedBatch(sk, batch2) + require.NoError(err, "SignProposedBatch") + nonExistingSignerBatch2, err := commitment.SignProposedBatch(nonExistingSigner, batch2) + require.NoError(err, "SignProposedBatch") + + // Executor commit. + body := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: blk.Header.Round, + PreviousHash: blk.Header.PreviousHash, + IORoot: &blk.Header.IORoot, + StateRoot: &blk.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + signedCommitment1, err := commitment.SignExecutorCommitment(sk, &body) + require.NoError(err, "SignExecutorCommitment") + body.Header.PreviousHash = hash.NewFromBytes([]byte("invalid ioroot")) + signedCommitment2, err := commitment.SignExecutorCommitment(sk, &body) + require.NoError(err, "SignExecutorCommitment") + + // Expired evidence. + blk2.Header.Round = 25 + expired1 := &commitment.ProposedBatch{ + IORoot: blk2.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: blk2.Header, + } + expiredB1, err := commitment.SignProposedBatch(sk, expired1) + require.NoError(err, "SignProposedBatch") + expired2 := &commitment.ProposedBatch{ + IORoot: hash.NewFromBytes([]byte("invalid root")), + StorageSignatures: []signature.Signature{}, + Header: blk2.Header, + } + expiredB2, err := commitment.SignProposedBatch(sk, expired2) + require.NoError(err, "SignProposedBatch") + + expiredBody := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: blk2.Header.Round, + PreviousHash: blk2.Header.PreviousHash, + IORoot: &blk2.Header.IORoot, + StateRoot: &blk2.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + expiredCommitment1, err := commitment.SignExecutorCommitment(sk, &expiredBody) + require.NoError(err, "SignExecutorCommitment") + expiredBody.Header.PreviousHash = hash.NewFromBytes([]byte("invalid ioroot")) + expiredCommitment2, err := commitment.SignExecutorCommitment(sk, &expiredBody) + require.NoError(err, "SignExecutorCommitment") + var md testMsgDispatcher + app := rootHashApplication{appState, &md} + + for _, ev := range []struct { + ev *roothash.Evidence + err error + msg string + }{ + { + &roothash.Evidence{}, + roothash.ErrInvalidEvidence, + "invalid evidence", + }, + { + &roothash.Evidence{ + ID: common.NewTestNamespaceFromSeed([]byte("tendermint/apps/roothash/transaction_test: non existing runtime"), 0), + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + }, + roothash.ErrInvalidRuntime, + "evidence for nonexisting runtime", + }, + { + &roothash.Evidence{ + ID: runtimeNoSlashing.ID, + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + }, + roothash.ErrRuntimeDoesNotSlash, + "evidence for runtime without slashing", + }, + { + &roothash.Evidence{ + ID: runtimeZeroSlashing.ID, + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + }, + roothash.ErrRuntimeDoesNotSlash, + "evidence for runtime with zero slashing", + }, + { + &roothash.Evidence{ + EquivocationExecutor: &roothash.EquivocationExecutorEvidence{ + CommitA: *expiredCommitment1, + CommitB: *expiredCommitment2, + }, + }, + roothash.ErrInvalidEvidence, + "expired executor evidence", + }, + { + &roothash.Evidence{ + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *expiredB1, + BatchB: *expiredB2, + }, + }, + roothash.ErrInvalidEvidence, + "expired batch evidence", + }, + { + &roothash.Evidence{ + ID: runtime.ID, + EquivocationExecutor: &roothash.EquivocationExecutorEvidence{ + CommitA: *signedCommitment1, + CommitB: *signedCommitment2, + }, + }, + nil, + "valid executor evidence", + }, + { + &roothash.Evidence{ + ID: runtime.ID, + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + }, + nil, + "valid batch evidence", + }, + { + &roothash.Evidence{ + ID: runtime.ID, + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + }, + roothash.ErrDuplicateEvidence, + "duplicate evidence", + }, + { + &roothash.Evidence{ + EquivocationBatch: &roothash.EquivocationBatchEvidence{ + BatchA: *nonExistingSignerBatch1, + BatchB: *nonExistingSignerBatch2, + }, + }, + roothash.ErrInvalidEvidence, + "evidence for non-existing node", + }, + } { + err = app.submitEvidence(ctx, roothashState, ev.ev) + require.ErrorIs(err, ev.err, ev.msg) + } + + // Check that expected amount was slashed. + // Entity should be slashed two times. + require.NoError(entityEscrow.Sub(slashAmount)) + require.NoError(entityEscrow.Sub(slashAmount)) + + entAcc, err := stakingState.Account(ctx, staking.NewAddress(nod.EntityID)) + require.NoError(err, "Account()") + require.EqualValues(entityEscrow, &entAcc.Escrow.Active.Balance, "entity was slashed expected amount") +} diff --git a/go/consensus/tendermint/apps/staking/state/state.go b/go/consensus/tendermint/apps/staking/state/state.go index 1d97d2edc7d..3dbc8106f5b 100644 --- a/go/consensus/tendermint/apps/staking/state/state.go +++ b/go/consensus/tendermint/apps/staking/state/state.go @@ -697,18 +697,24 @@ func (s *MutableState) SlashEscrow( // to the general balance of the account, returning true iff the // amount transferred is > 0. // +// If the escrow flag is true then the amount is escrowed instead of being +// transferred. The escrow operation takes the entity's commission rate into +// account and the rest is distributed to all delegators equally. +// // WARNING: This is an internal routine to be used to implement incentivization // policy, and MUST NOT be exposed outside of backend implementations. 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 +723,95 @@ 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 com *quantity.Quantity + switch to.Escrow.Active.TotalShares.IsZero() { + case false: + // Compute commission. + var epoch beacon.EpochTime + epoch, err = ctx.AppState().GetCurrentEpoch(ctx) + if err != nil { + return false, fmt.Errorf("tendermint/staking: failed to get current epoch: %w", err) + } + + q := transferred.Clone() + rate := to.Escrow.CommissionSchedule.CurrentRate(epoch) + if rate != nil { + com = q.Clone() + // Multiply first. + if err = com.Mul(rate); err != nil { + return false, fmt.Errorf("tendermint/staking: failed multiplying by commission rate: %w", err) + } + if err = com.Quo(staking.CommissionRateDenominator); err != nil { + return false, fmt.Errorf("tendermint/staking: failed dividing by commission rate denominator: %w", err) + } + + if err = q.Sub(com); err != nil { + return false, fmt.Errorf("tendermint/staking: failed subtracting commission: %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) + // Escrow everything except the commission (increases value of all shares). + if err = quantity.Move(&to.Escrow.Active.Balance, &to.General.Balance, q); err != nil { + return false, fmt.Errorf("tendermint/staking: failed transferring to active escrow balance from common pool: %w", err) + } + case true: + // If nothing has been escrowed before, everything counts as commission. + com = transferred.Clone() } - if err = s.SetAccount(ctx, toAddr, to); err != nil { - return false, fmt.Errorf("tendermint/staking: failed to set account %s: %w", toAddr, err) + + // Escrow commission. + if com != nil && !com.IsZero() { + 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) + } + + if err = to.Escrow.Active.Deposit(&delegation.Shares, &to.General.Balance, com); err != nil { + return false, fmt.Errorf("tendermint/staking: failed to deposit to escrow: %w", 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() { + 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() { + switch escrow { + case false: ev := cbor.Marshal(&staking.TransferEvent{ From: staking.CommonPoolAddress, To: toAddr, Amount: *transferred, }) ctx.EmitEvent(api.NewEventBuilder(AppName).Attribute(KeyTransfer, ev)) + case true: + ev := cbor.Marshal(&staking.AddEscrowEvent{ + Owner: staking.CommonPoolAddress, + Escrow: toAddr, + Amount: *transferred, + }) + 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/consensus/tendermint/apps/staking/state/state_test.go b/go/consensus/tendermint/apps/staking/state/state_test.go index 727949aba51..82f4fd2efa7 100644 --- a/go/consensus/tendermint/apps/staking/state/state_test.go +++ b/go/consensus/tendermint/apps/staking/state/state_test.go @@ -408,3 +408,126 @@ func TestProposalDeposits(t *testing.T) { require.NoError(t, err, "Account") require.EqualValues(t, *quantity.NewFromUint64(200), acc2.General.Balance, "governance deposit should be reclaimed") } + +func TestTransferFromCommon(t *testing.T) { + require := require.New(t) + + now := time.Unix(1580461674, 0) + appState := abciAPI.NewMockApplicationState(&abciAPI.MockApplicationStateConfig{}) + ctx := appState.NewContext(abciAPI.ContextDeliverTx, now) + defer ctx.Close() + + // Prepare state. + s := NewMutableState(ctx.State()) + pk1 := signature.NewPublicKey("aaafffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + addr1 := staking.NewAddress(pk1) + pk2 := signature.NewPublicKey("bbbfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + addr2 := staking.NewAddress(pk2) + + err := s.SetCommonPool(ctx, quantity.NewFromUint64(1000)) + require.NoError(err, "SetCommonPool") + + // Transfer without escrow. + ok, err := s.TransferFromCommon(ctx, addr1, quantity.NewFromUint64(100), false) + require.NoError(err, "TransferFromCommon without escrow") + require.True(ok, "TransferFromCommon should succeed") + + acc1, err := s.Account(ctx, addr1) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(100), acc1.General.Balance, "amount should be transferred to general balance") + require.EqualValues(*quantity.NewFromUint64(0), acc1.Escrow.Active.Balance, "nothing should be escrowed") + + // Transfer with escrow (no delegations). + ok, err = s.TransferFromCommon(ctx, addr2, quantity.NewFromUint64(100), true) + require.NoError(err, "TransferFromCommon with escrow (no delegations)") + require.True(ok, "TransferFromCommon should succeed") + + acc2, err := s.Account(ctx, addr2) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(0), acc2.General.Balance, "nothing should be in general balance") + require.EqualValues(*quantity.NewFromUint64(100), acc2.Escrow.Active.Balance, "amount should be escrowed") + dg, err := s.Delegation(ctx, addr2, addr2) + require.NoError(err, "Delegation") + require.EqualValues(*quantity.NewFromUint64(100), dg.Shares, "amount should be self-delegated") + + // Transfer with escrow (existing self-delegation). + ok, err = s.TransferFromCommon(ctx, addr2, quantity.NewFromUint64(100), true) + require.NoError(err, "TransferFromCommon with escrow (existing self-delegation)") + require.True(ok, "TransferFromCommon should succeed") + + acc2, err = s.Account(ctx, addr2) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(0), acc2.General.Balance, "nothing should be in general balance") + require.EqualValues(*quantity.NewFromUint64(200), acc2.Escrow.Active.Balance, "amount should be escrowed") + dg, err = s.Delegation(ctx, addr2, addr2) + require.NoError(err, "Delegation") + require.EqualValues(*quantity.NewFromUint64(100), dg.Shares, "shares should stay the same") + + // Transfer with escrow (existing self-delegation and commission). + acc2.Escrow.CommissionSchedule = staking.CommissionSchedule{ + Rates: []staking.CommissionRateStep{{ + Start: 0, + Rate: *quantity.NewFromUint64(20_000), // 20% + }}, + Bounds: []staking.CommissionRateBoundStep{{ + Start: 0, + RateMin: *quantity.NewFromUint64(0), + RateMax: *quantity.NewFromUint64(100_000), // 100% + }}, + } + err = s.SetAccount(ctx, addr2, acc2) + require.NoError(err, "SetAccount") + + ok, err = s.TransferFromCommon(ctx, addr2, quantity.NewFromUint64(100), true) + require.NoError(err, "TransferFromCommon with escrow (existing self-delegation and commission)") + require.True(ok, "TransferFromCommon should succeed") + + acc2, err = s.Account(ctx, addr2) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(0), acc2.General.Balance, "nothing should be in general balance") + require.EqualValues(*quantity.NewFromUint64(300), acc2.Escrow.Active.Balance, "amount should be escrowed") + dg, err = s.Delegation(ctx, addr2, addr2) + require.NoError(err, "Delegation") + require.EqualValues(*quantity.NewFromUint64(107), dg.Shares, "20%% of amount should go towards increasing self-delegation") + + // Transfer with escrow (other delegations and commission). + var dg2 staking.Delegation + err = acc2.Escrow.Active.Deposit(&dg2.Shares, quantity.NewFromUint64(100), quantity.NewFromUint64(100)) + require.NoError(err, "Deposit") + err = s.SetDelegation(ctx, addr1, addr2, &dg2) + require.NoError(err, "SetDelegation") + + ok, err = s.TransferFromCommon(ctx, addr2, quantity.NewFromUint64(1000), true) + require.NoError(err, "TransferFromCommon with escrow (existing delegations and commission)") + require.True(ok, "TransferFromCommon should succeed") + + acc2, err = s.Account(ctx, addr2) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(0), acc2.General.Balance, "nothing should be in general balance") + require.EqualValues(*quantity.NewFromUint64(900), acc2.Escrow.Active.Balance, "remaining amount should be escrowed") + dg, err = s.Delegation(ctx, addr2, addr2) + require.NoError(err, "Delegation") + require.EqualValues(*quantity.NewFromUint64(123), dg.Shares, "20%% of amount should go towards increasing self-delegation") + + acc1, err = s.Account(ctx, addr1) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(100), acc1.General.Balance, "general balance should be unchanged") + dg, err = s.Delegation(ctx, addr1, addr2) + require.NoError(err, "Delegation") + require.EqualValues(dg2.Shares, dg.Shares, "delegated amount should be unchanged") + + // There should be nothing left in the common pool. + cp, err := s.CommonPool(ctx) + require.NoError(err, "CommonPool") + require.True(cp.IsZero(), "common pool should be depleted after all the transfers") + + // Transfer from empty common pool should have no effect. + ok, err = s.TransferFromCommon(ctx, addr1, quantity.NewFromUint64(100), false) + require.NoError(err, "TransferFromCommon from depleted common pool") + require.False(ok, "TransferFromCommon should indicate that nothing was transferred") + + acc1, err = s.Account(ctx, addr1) + require.NoError(err, "Account") + require.EqualValues(*quantity.NewFromUint64(100), acc1.General.Balance, "amount should be unchanged") + require.EqualValues(*quantity.NewFromUint64(0), acc1.Escrow.Active.Balance, "escrow amount should be unchanged") +} diff --git a/go/genesis/genesis_test.go b/go/genesis/genesis_test.go index 584bad84a4f..3ac75eb37b2 100644 --- a/go/genesis/genesis_test.go +++ b/go/genesis/genesis_test.go @@ -141,7 +141,7 @@ func TestGenesisChainContext(t *testing.T) { // Having to update this every single time the genesis structure // changes isn't annoying at all. - require.Equal(t, "190c33831058e1f850a1528da9892259c4d86538f4524c561a93505fe41c4d83", stableDoc.ChainContext()) + require.Equal(t, "c7ca04c2279b2df5773258fda6a7ff9e473fe648eb7616e1be6474308e9174e8", stableDoc.ChainContext()) } func TestGenesisSanityCheck(t *testing.T) { diff --git a/go/oasis-node/cmd/registry/runtime/runtime.go b/go/oasis-node/cmd/registry/runtime/runtime.go index f1fb0bf2a51..1355a855245 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" @@ -457,7 +458,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) @@ -542,6 +563,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..903590319fe 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,42 @@ func (sc *byzantineImpl) Fixture() (*oasis.NetworkFixture, error) { return nil, err } + // Add another entity (DeterministicEntity2) that will get slashed. + f.Entities = append(f.Entities, oasis.EntityCfg{}) + + 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.DeterministicEntity2: { + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *quantity.NewFromUint64(100), + TotalShares: *quantity.NewFromUint64(100), + }, + }, + }, + }, + Delegations: map[staking.Address]map[staking.Address]*staking.Delegation{ + e2e.DeterministicEntity2: { + e2e.DeterministicEntity2: &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 @@ -253,7 +319,7 @@ func (sc *byzantineImpl) Fixture() (*oasis.NetworkFixture, error) { Script: sc.script, ExtraArgs: sc.extraArgs, IdentitySeed: sc.identitySeed, - Entity: 1, + Entity: 2, ActivationEpoch: 1, }, } @@ -261,6 +327,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 +342,33 @@ 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.DeterministicEntity2, + }) + if err != nil { + return err + } + + // Calculate expected stake by going through expected slashes. + expectedStake := fixture.Network.StakingGenesis.Ledger[e2e.DeterministicEntity2].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 c2e3a958a02..b61e04e2373 100644 --- a/go/registry/api/runtime.go +++ b/go/registry/api/runtime.go @@ -263,10 +263,27 @@ type RuntimeStakingParameters struct { // In case a node is registered for multiple runtimes, it will need to satisfy the maximum // threshold of all the runtimes. Thresholds map[staking.ThresholdKind]quantity.Quantity `json:"thresholds,omitempty"` + + // 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 7dca6771513..efd25baed19 100644 --- a/go/registry/tests/tester.go +++ b/go/registry/tests/tester.go @@ -6,6 +6,7 @@ import ( "crypto" "errors" "fmt" + "math" "net" "testing" "time" @@ -1732,6 +1733,14 @@ func NewTestRuntime(seed []byte, ent *TestEntity, isKeyManager bool) (*TestRunti AnyNode: &api.AnyNodeRuntimeAdmissionPolicy{}, }, GovernanceModel: api.GovernanceEntity, + Staking: api.RuntimeStakingParameters{ + Slashing: map[staking.SlashReason]staking.Slash{ + staking.SlashRuntimeEquivocation: { + Amount: *quantity.NewFromUint64(math.MaxInt64), + }, + }, + RewardSlashEquvocationRuntimePercent: 100, + }, } // TODO: Test with non-empty state root when enabled. rt.Runtime.Genesis.StateRoot.Empty() diff --git a/go/roothash/api/api.go b/go/roothash/api/api.go index 59cc953fde3..00ac18952a6 100644 --- a/go/roothash/api/api.go +++ b/go/roothash/api/api.go @@ -63,16 +63,30 @@ var ( // larger than the MaxRuntimeMessages specified in consensus parameters. ErrMaxMessagesTooBig = errors.New(ModuleName, 7, "roothash: max runtime messages is too big") + // ErrRuntimeDoesNotSlash is the error returned when misbehaviour evidence is submitted for a + // runtime that does not slash. + ErrRuntimeDoesNotSlash = errors.New(ModuleName, 8, "roothash: runtime does not slash") + + // ErrDuplicateEvidence is the error returned when submitting already existing evidence. + ErrDuplicateEvidence = errors.New(ModuleName, 9, "roothash: duplicate evidence") + + // ErrInvalidEvidence is the error return when an invalid evidence is submitted. + ErrInvalidEvidence = errors.New(ModuleName, 10, "roothash: invalid evidence") + // MethodExecutorCommit is the method name for executor commit submission. MethodExecutorCommit = transaction.NewMethodName(ModuleName, "ExecutorCommit", ExecutorCommit{}) - // MethodExecutorProposerTimeout is the method name for executor. + // MethodExecutorProposerTimeout is the method name for executor proposer timeout. MethodExecutorProposerTimeout = transaction.NewMethodName(ModuleName, "ExecutorProposerTimeout", ExecutorProposerTimeoutRequest{}) + // MethodEvidence is the method name for submitting evidence of node misbehavior. + MethodEvidence = transaction.NewMethodName(ModuleName, "Evidence", Evidence{}) + // Methods is a list of all methods supported by the roothash backend. Methods = []transaction.MethodName{ MethodExecutorCommit, MethodExecutorProposerTimeout, + MethodEvidence, } ) @@ -142,6 +156,149 @@ func NewRequestProposerTimeoutTx(nonce uint64, fee *transaction.Fee, runtimeID c }) } +// EvidenceKind is the evidence kind. +type EvidenceKind uint8 + +const ( + // EvidenceKindEquivocation is the evidence kind for equivocation. + EvidenceKindEquivocation = 1 +) + +// Evidence is an evidence of node misbehaviour. +type Evidence struct { + ID common.Namespace `json:"id"` + + EquivocationExecutor *EquivocationExecutorEvidence `json:"equivocation_executor,omitempty"` + EquivocationBatch *EquivocationBatchEvidence `json:"equivocation_batch,omitempty"` +} + +// Hash computes the evidence hash. +// +// Hash is derived by hashing the evidence kind and the public key of the signer. +// Assumes evidence has been validated. +func (ev *Evidence) Hash() (hash.Hash, error) { + switch { + case ev.EquivocationBatch != nil: + return hash.NewFromBytes([]byte{EvidenceKindEquivocation}, ev.EquivocationBatch.BatchA.Signature.PublicKey[:]), nil + case ev.EquivocationExecutor != nil: + return hash.NewFromBytes([]byte{EvidenceKindEquivocation}, ev.EquivocationExecutor.CommitA.Signature.PublicKey[:]), nil + default: + return hash.Hash{}, fmt.Errorf("cannot compute hash, invalid evidence") + } +} + +// ValidateBasic performs basic evidence validity checks. +func (ev *Evidence) ValidateBasic() error { + switch { + case ev.EquivocationExecutor != nil && ev.EquivocationBatch != nil: + return fmt.Errorf("evidence has multiple fields set") + case ev.EquivocationExecutor != nil: + return ev.EquivocationExecutor.ValidateBasic() + case ev.EquivocationBatch != nil: + return ev.EquivocationBatch.ValidateBasic() + default: + return fmt.Errorf("evidence content has no fields set") + } +} + +// EquivocationExecutorEvidence is evidence of executor commitment equivocation. +type EquivocationExecutorEvidence struct { + CommitA commitment.ExecutorCommitment `json:"commit_a"` + CommitB commitment.ExecutorCommitment `json:"commit_b"` +} + +// ValidateBasic performs stateless executor evidence validation checks. +// +// Particularly evidence is not verified to not be expired as this requires stateful checks. +func (ev *EquivocationExecutorEvidence) ValidateBasic() error { + if ev.CommitA.Equal(&ev.CommitB) { + return fmt.Errorf("commits are equal, no sign of equivocation") + } + + if !ev.CommitA.Signature.PublicKey.Equal(ev.CommitB.Signature.PublicKey) { + return fmt.Errorf("equivocation executor evidence signature public keys don't match") + } + + a, err := ev.CommitA.Open() + if err != nil { + return fmt.Errorf("opening CommitA: %w", err) + } + b, err := ev.CommitB.Open() + if err != nil { + return fmt.Errorf("opening CommitB: %w", err) + } + + if a.Body.Header.Round != b.Body.Header.Round { + return fmt.Errorf("equivocation evidence commit headers not for same round") + } + + if err := a.Body.ValidateBasic(); err != nil { + return fmt.Errorf("equivocation evidence commit A not valid: %w", err) + } + if err := b.Body.ValidateBasic(); err != nil { + return fmt.Errorf("equivocation evidence commit B not valid: %w", err) + } + + switch { + // Note: ValidBasics checks above ensure that none of these fields are nil. + case a.Body.Failure == commitment.FailureNone && b.Body.Failure == commitment.FailureNone: + if a.Body.Header.PreviousHash.Equal(&b.Body.Header.PreviousHash) && + a.Body.Header.IORoot.Equal(b.Body.Header.IORoot) && + a.Body.Header.StateRoot.Equal(b.Body.Header.StateRoot) && + a.Body.Header.MessagesHash.Equal(b.Body.Header.MessagesHash) { + return fmt.Errorf("equivocation evidence commit headers match, no sign of equivocation") + } + default: + if a.Body.Failure == b.Body.Failure { + return fmt.Errorf("equivocation evidence failure indication fields match, no sign of equivocation") + } + } + + return nil +} + +// EquivocationBatchEvidence is evidence of executor proposed batch equivocation. +type EquivocationBatchEvidence struct { + BatchA commitment.SignedProposedBatch `json:"batch_a"` + BatchB commitment.SignedProposedBatch `json:"batch_b"` +} + +// ValidateBasic performs stateless batch evidence validation checks. +// +// Particularly evidence is not verified to not be expired as this requires stateful checks. +func (ev *EquivocationBatchEvidence) ValidateBasic() error { + if ev.BatchA.Equal(&ev.BatchB) { + return fmt.Errorf("batches are equal, no sign of equivocation") + } + + if !ev.BatchA.Signature.PublicKey.Equal(ev.BatchB.Signature.PublicKey) { + return fmt.Errorf("equivocation batch evidence signature public keys don't match") + } + + var a, b commitment.ProposedBatch + if err := ev.BatchA.Open(&a); err != nil { + return fmt.Errorf("opening BatchA: %w", err) + } + if err := ev.BatchB.Open(&b); err != nil { + return fmt.Errorf("opening BatchB: %w", err) + } + + if a.Header.Round != b.Header.Round { + return fmt.Errorf("equivocation evidence batch header rounds don't match") + } + + if a.IORoot.Equal(&b.IORoot) && a.Header.MostlyEqual(&b.Header) { + return fmt.Errorf("equivocation evidence batch io roots match, no sign of equivocation") + } + + return nil +} + +// NewEvidenceTx creates a new evidence transaction. +func NewEvidenceTx(nonce uint64, fee *transaction.Fee, evidence *Evidence) *transaction.Transaction { + return transaction.NewTransaction(nonce, fee, MethodEvidence, evidence) +} + // RuntimeState is the per-runtime state. type RuntimeState struct { Runtime *registry.Runtime `json:"runtime"` @@ -256,6 +413,9 @@ type ConsensusParameters struct { // MaxRuntimeMessages is the maximum number of allowed messages that can be emitted by a runtime // in a single round. MaxRuntimeMessages uint32 `json:"max_runtime_messages"` + + // MaxEvidenceAge is the maximum age of submitted evidence in the number of rounds. + MaxEvidenceAge uint64 `json:"max_evidence_age"` } const ( @@ -264,6 +424,9 @@ const ( // GasOpProposerTimeout is the gas operation identifier for executor propose timeout cost. GasOpProposerTimeout transaction.Op = "proposer_timeout" + + // GasOpEvidence is the gas operation identifier for evidence submission transaction cost. + GasOpEvidence transaction.Op = "evidence" ) // XXX: Define reasonable default gas costs. @@ -272,6 +435,7 @@ const ( var DefaultGasCosts = transaction.Costs{ GasOpComputeCommit: 1000, GasOpProposerTimeout: 1000, + GasOpEvidence: 1000, } // SanityCheckBlocks examines the blocks table. diff --git a/go/roothash/api/api_test.go b/go/roothash/api/api_test.go new file mode 100644 index 00000000000..936f73191cb --- /dev/null +++ b/go/roothash/api/api_test.go @@ -0,0 +1,548 @@ +package api + +import ( + "crypto/rand" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/oasisprotocol/oasis-core/go/common" + "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + memorySigner "github.com/oasisprotocol/oasis-core/go/common/crypto/signature/signers/memory" + genesisTestHelpers "github.com/oasisprotocol/oasis-core/go/genesis/tests" + "github.com/oasisprotocol/oasis-core/go/roothash/api/block" + "github.com/oasisprotocol/oasis-core/go/roothash/api/commitment" +) + +func TestEvidenceHash(t *testing.T) { + require := require.New(t) + + genesisTestHelpers.SetTestChainContext() + + // Test empty evidence. + ev := Evidence{} + _, err := ev.Hash() + require.Error(err, "empty evidence hash should error") + + // Prepare valid evidence. + sk, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + sk2, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + runtimeID := common.NewTestNamespaceFromSeed([]byte("roothash/api_test/hash: runtime"), 0) + blk := block.NewGenesisBlock(runtimeID, 0) + batch1 := &commitment.ProposedBatch{ + IORoot: blk.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: blk.Header, + } + signedBatch1, err := commitment.SignProposedBatch(sk, batch1) + require.NoError(err, "SignProposedBatch") + signed2Batch1, err := commitment.SignProposedBatch(sk2, batch1) + require.NoError(err, "SignedProposedBatch") + + batch2 := &commitment.ProposedBatch{ + IORoot: hash.NewFromBytes([]byte("invalid root")), + StorageSignatures: []signature.Signature{}, + Header: blk.Header, + } + signedBatch2, err := commitment.SignProposedBatch(sk, batch2) + require.NoError(err, "SignProposedBatch") + signed2Batch2, err := commitment.SignProposedBatch(sk2, batch2) + require.NoError(err, "SignedProposedBatch") + + // Executor commit. + body := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: blk.Header.Round, + PreviousHash: blk.Header.PreviousHash, + IORoot: &blk.Header.IORoot, + StateRoot: &blk.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + signedCommitment1, err := commitment.SignExecutorCommitment(sk, &body) + require.NoError(err, "SignExecutorCommitment") + + ev = Evidence{ + ID: runtimeID, + EquivocationBatch: &EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + } + h1, err := ev.Hash() + require.NoError(err, "Hash") + + ev = Evidence{ + ID: runtimeID, + EquivocationExecutor: &EquivocationExecutorEvidence{ + // Same round and same signer as above evidence, hash should match. + CommitA: *signedCommitment1, + CommitB: *signedCommitment1, + }, + } + h2, err := ev.Hash() + require.NoError(err, "Hash") + require.EqualValues(h1, h2, "Equivocation evidence hashes for same round by same signer should match") + + ev = Evidence{ + ID: runtimeID, + EquivocationBatch: &EquivocationBatchEvidence{ + BatchA: *signed2Batch1, + BatchB: *signed2Batch2, + }, + } + h3, err := ev.Hash() + require.NoError(err, "Hash") + require.NotEqualValues(h1, h3, "Equivocation evidence hashes for same round by different signers shouldn't match") +} + +func TestEvidenceValidateBasic(t *testing.T) { + require := require.New(t) + + genesisTestHelpers.SetTestChainContext() + + sk, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + + rtID := common.NewTestNamespaceFromSeed([]byte("roothash/api_test: runtime1"), 0) + rtBlk := block.NewGenesisBlock(rtID, 0) + rtBatch1 := &commitment.ProposedBatch{ + IORoot: rtBlk.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: rtBlk.Header, + } + signedB1, err := commitment.SignProposedBatch(sk, rtBatch1) + require.NoError(err, "SignProposedBatch") + + rtBatch2 := &commitment.ProposedBatch{ + IORoot: hash.NewFromBytes([]byte("invalid root")), + StorageSignatures: []signature.Signature{}, + Header: rtBlk.Header, + } + signedB2, err := commitment.SignProposedBatch(sk, rtBatch2) + require.NoError(err, "SignProposedBatch") + + body := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: rtBlk.Header.Round, + PreviousHash: rtBlk.Header.PreviousHash, + IORoot: &rtBlk.Header.IORoot, + StateRoot: &rtBlk.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + signedCommitment1, err := commitment.SignExecutorCommitment(sk, &body) + require.NoError(err, "SignExecutorCommitment") + + body.Header.PreviousHash = hash.NewFromBytes([]byte("invalid hash")) + signedCommitment2, err := commitment.SignExecutorCommitment(sk, &body) + require.NoError(err, "SignExecutorCommitment") + + for _, ev := range []struct { + ev Evidence + shouldErr bool + msg string + }{ + { + Evidence{}, + true, + "empty evidence should error", + }, + { + Evidence{ + ID: rtID, + EquivocationExecutor: &EquivocationExecutorEvidence{ + CommitA: *signedCommitment1, + CommitB: *signedCommitment2, + }, + EquivocationBatch: &EquivocationBatchEvidence{ + BatchA: *signedB1, + BatchB: *signedB2, + }, + }, + true, + "evidence with multiple evidence types should error", + }, + { + Evidence{ + ID: rtID, + EquivocationExecutor: &EquivocationExecutorEvidence{ + CommitA: *signedCommitment1, + CommitB: *signedCommitment2, + }, + }, + false, + "valid equivocation executor evidence", + }, + { + Evidence{ + ID: rtID, + EquivocationBatch: &EquivocationBatchEvidence{ + BatchA: *signedB1, + BatchB: *signedB2, + }, + }, + false, + "valid equivocation batch evidence", + }, + } { + err := ev.ev.ValidateBasic() + switch ev.shouldErr { + case true: + require.Error(err, ev.msg) + case false: + require.NoError(err, ev.msg) + } + } +} + +func TestEquivocationBatchEvidenceValidateBasic(t *testing.T) { + require := require.New(t) + + genesisTestHelpers.SetTestChainContext() + + sk, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + + sk2, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + + rt1ID := common.NewTestNamespaceFromSeed([]byte("roothash/api_test: runtime1"), 0) + rt1Blk1 := block.NewGenesisBlock(rt1ID, 0) + rt1Blk2 := block.NewEmptyBlock(rt1Blk1, 0, block.Normal) // Different round. + rt1Blk3 := block.NewGenesisBlock(rt1ID, 0xDEADBEEF) // Same round, different timestamp. + + rt2ID := common.NewTestNamespaceFromSeed([]byte("roothash/api_test: runtime2"), 0) + rt2Blk1 := block.NewGenesisBlock(rt2ID, 0) + rt2Blk2 := block.NewEmptyBlock(rt2Blk1, 0, block.Invalid) + + // Prepare test signed batches. + rt1Batch1 := &commitment.ProposedBatch{ + IORoot: rt1Blk1.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: rt1Blk1.Header, + } + signedR1B1, err := commitment.SignProposedBatch(sk, rt1Batch1) + require.NoError(err, "SignProposedBatch") + + rt1Batch2 := &commitment.ProposedBatch{ + IORoot: rt1Blk2.Header.IORoot, // Different IO root. + StorageSignatures: []signature.Signature{}, // Same storage signatures. + Header: rt1Blk2.Header, // Different header. + } + signedR1B2, err := commitment.SignProposedBatch(sk, rt1Batch2) + require.NoError(err, "SignProposedBatch") + + rt1Batch3 := &commitment.ProposedBatch{ + IORoot: hash.NewFromBytes([]byte("invalid root")), // Different IO root. + StorageSignatures: []signature.Signature{}, // Same storage signatures. + Header: rt1Blk1.Header, // Same header. + } + signedR1B3, err := commitment.SignProposedBatch(sk, rt1Batch3) + require.NoError(err, "SignProposedBatch") + + rt1Batch4 := &commitment.ProposedBatch{ + IORoot: rt1Batch1.IORoot, // Same IO root. + StorageSignatures: []signature.Signature{{}}, // Different storage signatures. + Header: rt1Batch1.Header, // Same header. + } + signedR1B4, err := commitment.SignProposedBatch(sk, rt1Batch4) + require.NoError(err, "SignProposedBatch") + + rt1Batch5 := &commitment.ProposedBatch{ + IORoot: rt1Batch1.IORoot, // Same IO root. + StorageSignatures: []signature.Signature{}, // Same storage signatures. + Header: rt1Blk3.Header, // Different header for same round. + } + signedR1B5, err := commitment.SignProposedBatch(sk, rt1Batch5) + require.NoError(err, "SignProposedBatch") + + signed2R1B3, err := commitment.SignProposedBatch(sk2, rt1Batch3) + require.NoError(err, "SignProposedBatch") + + rt2Batch1 := &commitment.ProposedBatch{ + IORoot: rt2Blk2.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: rt2Blk2.Header, + } + signedR2B1, err := commitment.SignProposedBatch(sk, rt2Batch1) + require.NoError(err, "SignProposedBatch") + + for _, ev := range []struct { + ev EquivocationBatchEvidence + shouldErr bool + msg string + }{ + { + EquivocationBatchEvidence{}, + true, + "empty evidence should error", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + }, + true, + "empty evidence should error", + }, + { + EquivocationBatchEvidence{ + BatchB: *signedR1B1, + }, + true, + "empty evidence should error", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signedR1B1, + }, + true, + "same signed batch is not valid evidence", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signedR1B2, + }, + true, + "signed batches for different heights is not valid evidence", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signedR2B1, + }, + true, + "signed batches for different runtimes is not valid evidence", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signedR1B3, + }, + false, + "same round different IORoot is valid evidence", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signed2R1B3, + }, + true, + "different signer is not valid evidence", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signedR1B4, + }, + true, + "same round, io root and same header is not valid evidence", + }, + { + EquivocationBatchEvidence{ + BatchA: *signedR1B1, + BatchB: *signedR1B5, + }, + false, + "same round and io root but different header is valid evidence", + }, + } { + err := ev.ev.ValidateBasic() + switch ev.shouldErr { + case true: + require.Error(err, ev.msg) + case false: + require.NoError(err, ev.msg) + } + } +} + +func TestEquivocationExecutorEvidenceValidateBasic(t *testing.T) { + require := require.New(t) + + genesisTestHelpers.SetTestChainContext() + + sk, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + + sk2, err := memorySigner.NewSigner(rand.Reader) + require.NoError(err, "NewSigner") + + rt1ID := common.NewTestNamespaceFromSeed([]byte("roothash/api_test: runtime1"), 0) + rt1Blk1 := block.NewGenesisBlock(rt1ID, 0) + rt1Blk2 := block.NewEmptyBlock(rt1Blk1, 0, block.Normal) + + body := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: rt1Blk2.Header.Round, + PreviousHash: rt1Blk2.Header.PreviousHash, + IORoot: &rt1Blk2.Header.IORoot, + StateRoot: &rt1Blk2.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + signed1Commitment, err := commitment.SignExecutorCommitment(sk, &body) + require.NoError(err, "SignExecutorCommitment") + signed2Commitment, err := commitment.SignExecutorCommitment(sk2, &body) + require.NoError(err, "SignExecutorCommitment") + + body2 := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: rt1Blk2.Header.Round, + PreviousHash: hash.NewFromBytes([]byte("invalid hash")), + IORoot: &rt1Blk2.Header.IORoot, + StateRoot: &rt1Blk2.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + signed1Commitment2, err := commitment.SignExecutorCommitment(sk, &body2) + require.NoError(err, "SignExecutorCommitment") + + // Different round. + body3 := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: rt1Blk2.Header.Round + 1, + PreviousHash: hash.NewFromBytes([]byte("invalid hash")), + IORoot: &rt1Blk2.Header.IORoot, + StateRoot: &rt1Blk2.Header.StateRoot, + MessagesHash: &hash.Hash{}, + }, + } + signed1Commitment3, err := commitment.SignExecutorCommitment(sk, &body3) + require.NoError(err, "SignExecutorCommitment") + + // Invalid. + invalidBody := commitment.ComputeBody{ + Header: commitment.ComputeResultsHeader{ + Round: rt1Blk2.Header.Round, + PreviousHash: hash.NewFromBytes([]byte("invalid hash")), + IORoot: nil, + StateRoot: &rt1Blk2.Header.StateRoot, + MessagesHash: nil, + }, + } + signed1Invalid, err := commitment.SignExecutorCommitment(sk, &invalidBody) + require.NoError(err, "SignExecutorCommitment") + + // Failure indicating. + failureBody1 := body + failureBody1.SetFailure(commitment.FailureStorageUnavailable) + signedFailure1, err := commitment.SignExecutorCommitment(sk, &failureBody1) + require.NoError(err, "SignExecutorCommitment") + + failureBody2 := body + failureBody2.SetFailure(commitment.FailureUnknown) + signedFailure2, err := commitment.SignExecutorCommitment(sk, &failureBody2) + require.NoError(err, "SignExecutorCommitment") + + for _, ev := range []struct { + ev EquivocationExecutorEvidence + shouldErr bool + msg string + }{ + { + EquivocationExecutorEvidence{}, + true, + "empty evidence should error", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + }, + true, + "empty evidence should error", + }, + { + EquivocationExecutorEvidence{ + CommitB: *signed1Commitment, + }, + true, + "empty evidence should error", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + CommitB: *signed1Commitment, + }, + true, + "same signed commitment is not valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + CommitB: *signed1Commitment3, + }, + true, + "signed commitments for different rounds is not valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + CommitB: *signed1Invalid, + }, + true, + "non valid commitment is not valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Invalid, + CommitB: *signed1Commitment, + }, + true, + "non valid commitment not valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signedFailure1, + CommitB: *signedFailure1, + }, + true, + "same failure indicating is not valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signedFailure1, + CommitB: *signedFailure2, + }, + false, + "different failure indicating reason is valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + CommitB: *signed2Commitment, + }, + true, + "different signer is not valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + CommitB: *signed1Commitment2, + }, + false, + "valid evidence", + }, + { + EquivocationExecutorEvidence{ + CommitA: *signed1Commitment, + CommitB: *signedFailure1, + }, + false, + "valid evidence", + }, + } { + err := ev.ev.ValidateBasic() + switch ev.shouldErr { + case true: + require.Error(err, ev.msg) + case false: + require.NoError(err, ev.msg) + } + } +} diff --git a/go/roothash/api/commitment/pool.go b/go/roothash/api/commitment/pool.go index 11e45ff05f8..e7a88388039 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 (oasis-core#3685). + switch openCom.IsIndicatingFailure() { case true: default: diff --git a/go/roothash/api/commitment/txnscheduler.go b/go/roothash/api/commitment/txnscheduler.go index 0ecc09b6ffe..30bd352ad0e 100644 --- a/go/roothash/api/commitment/txnscheduler.go +++ b/go/roothash/api/commitment/txnscheduler.go @@ -36,6 +36,11 @@ type SignedProposedBatch struct { signature.Signed } +// Equal compares vs another SignedProposedBatch for equality. +func (s *SignedProposedBatch) Equal(cmp *SignedProposedBatch) bool { + return s.Signed.Equal(&cmp.Signed) +} + // Open first verifies the blob signature and then unmarshals the blob. func (s *SignedProposedBatch) Open(tsbd *ProposedBatch) error { return s.Signed.Open(ProposedBatchSignatureContext, tsbd) diff --git a/go/roothash/tests/tester.go b/go/roothash/tests/tester.go index 87771a3e8d3..0ec8508ca81 100644 --- a/go/roothash/tests/tester.go +++ b/go/roothash/tests/tester.go @@ -17,6 +17,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/identity" + "github.com/oasisprotocol/oasis-core/go/common/quantity" consensusAPI "github.com/oasisprotocol/oasis-core/go/consensus/api" registryTests "github.com/oasisprotocol/oasis-core/go/registry/tests" "github.com/oasisprotocol/oasis-core/go/roothash/api" @@ -24,6 +25,8 @@ import ( "github.com/oasisprotocol/oasis-core/go/roothash/api/commitment" "github.com/oasisprotocol/oasis-core/go/runtime/transaction" scheduler "github.com/oasisprotocol/oasis-core/go/scheduler/api" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" + stakingTests "github.com/oasisprotocol/oasis-core/go/staking/tests" storageAPI "github.com/oasisprotocol/oasis-core/go/storage/api" "github.com/oasisprotocol/oasis-core/go/worker/storage" ) @@ -110,6 +113,10 @@ func RootHashImplementationTests(t *testing.T, backend api.Backend, consensus co t.Run("RoundTimeoutWithEpochTransition", func(t *testing.T) { testRoundTimeoutWithEpochTransition(t, backend, consensus, identity, rtStates) }) + + t.Run("EquivocationEvidence", func(t *testing.T) { + testSubmitEquivocationEvidence(t, backend, consensus, identity, rtStates) + }) } func testGenesisBlock(t *testing.T, backend api.Backend, state *runtimeState) { @@ -814,3 +821,90 @@ func MustTransitionEpoch( } } } + +func testSubmitEquivocationEvidence(t *testing.T, backend api.Backend, consensus consensusAPI.Backend, identity *identity.Identity, states []*runtimeState) { + require := require.New(t) + + ctx := context.Background() + + s := states[0] + child, err := backend.GetLatestBlock(ctx, s.rt.Runtime.ID, consensusAPI.HeightLatest) + require.NoError(err, "GetLatestBlock") + + // Generate and submit evidence of executor equivocation. + if len(s.executorCommittee.workers) < 2 { + t.Fatal("not enough executor nodes for running runtime misbehaviour evidence test") + } + + // Generate evidence of executor equivocation. + node := s.executorCommittee.workers[0] + batch1 := &commitment.ProposedBatch{ + IORoot: child.Header.IORoot, + StorageSignatures: []signature.Signature{}, + Header: child.Header, + } + signedBatch1, err := commitment.SignProposedBatch(node.Signer, batch1) + require.NoError(err, "SignProposedBatch") + + batch2 := &commitment.ProposedBatch{ + IORoot: hash.NewFromBytes([]byte("different root")), + StorageSignatures: []signature.Signature{}, + Header: child.Header, + } + signedBatch2, err := commitment.SignProposedBatch(node.Signer, batch2) + require.NoError(err, "SignProposedBatch") + + ch, sub, err := consensus.Staking().WatchEvents(ctx) + require.NoError(err, "staking.WatchEvents") + defer sub.Close() + + // Ensure misbehaving node entity has some stake. + entityAddress := staking.NewAddress(node.Node.EntityID) + escrow := &staking.Escrow{ + Account: entityAddress, + Amount: *quantity.NewFromUint64(100), + } + tx := staking.NewAddEscrowTx(0, nil, escrow) + err = consensusAPI.SignAndSubmitTx(ctx, consensus, stakingTests.SrcSigner, tx) + require.NoError(err, "AddEscrow") + + // Submit evidence of executor equivocation. + tx = api.NewEvidenceTx(0, nil, &api.Evidence{ + ID: s.rt.Runtime.ID, + EquivocationBatch: &api.EquivocationBatchEvidence{ + BatchA: *signedBatch1, + BatchB: *signedBatch2, + }, + }) + submitter := s.executorCommittee.workers[1] + err = consensusAPI.SignAndSubmitTx(ctx, consensus, submitter.Signer, tx) + require.NoError(err, "SignAndSubmitTx(EvidenceTx)") + + // Wait for the node to get slashed. +WaitLoop: + for { + select { + case ev := <-ch: + if ev.Escrow == nil { + continue + } + + if e := ev.Escrow.Take; e != nil { + require.EqualValues(entityAddress, e.Owner, "TakeEscrowEvent - owner must be entity's address") + // All stake must be slashed as defined in debugGenesisState. + require.EqualValues(escrow.Amount, e.Amount, "TakeEscrowEvent - all stake slashed") + break WaitLoop + } + case <-time.After(recvTimeout): + t.Fatalf("failed to receive slash event") + } + } + + // Ensure runtime acc got the slashed funds. + runtimeAcc, err := consensus.Staking().Account(ctx, &staking.OwnerQuery{ + Height: consensusAPI.HeightLatest, + Owner: staking.NewRuntimeAddress(s.rt.Runtime.ID), + }) + require.NoError(err, "staking.Account(runtimeAddr)") + require.EqualValues(escrow.Amount, runtimeAcc.General.Balance, "Runtime account expected salshed balance") +} diff --git a/go/staking/api/slashing.go b/go/staking/api/slashing.go index e414418976a..7e88c6b6ae2 100644 --- a/go/staking/api/slashing.go +++ b/go/staking/api/slashing.go @@ -22,6 +22,13 @@ const ( // SlashConsensusLightClientAttack is slashing due to light client attacks. SlashConsensusLightClientAttack SlashReason = 0x04 + // SlashRuntimeIncorrectResults is slashing due to submission of incorrect + // results in runtime executor commitments. + SlashRuntimeIncorrectResults SlashReason = 0x80 + // SlashRuntimeEquivocation is slashing due to signing two different + // executor commits or proposed batches for the same round. + SlashRuntimeEquivocation SlashReason = 0x81 + // SlashConsensusEquivocationName is the string representation of SlashConsensusEquivocation. SlashConsensusEquivocationName = "consensus-equivocation" // SlashBeaconInvalidCommitName is the string representation of SlashBeaconInvalidCommit. @@ -32,6 +39,10 @@ const ( SlashBeaconNonparticipationName = "beacon-nonparticipation" // SlashConsensusLightClientAttackName is the string representation of SlashConsensusLightClientAttack. SlashConsensusLightClientAttackName = "consensus-light-client-attack" + // SlashRuntimeIncorrectResultsName is the string representation of SlashRuntimeIncorrectResultsName. + SlashRuntimeIncorrectResultsName = "runtime-incorrect-results" + // SlashRuntimeEquivocationName is the string representation of SlashRuntimeEquivocation. + SlashRuntimeEquivocationName = "runtime-equivocation" ) // String returns a string representation of a SlashReason. @@ -52,6 +63,10 @@ func (s SlashReason) checkedString() (string, error) { return SlashBeaconNonparticipationName, nil case SlashConsensusLightClientAttack: return SlashConsensusLightClientAttackName, nil + case SlashRuntimeIncorrectResults: + return SlashRuntimeIncorrectResultsName, nil + case SlashRuntimeEquivocation: + return SlashRuntimeEquivocationName, nil default: return "[unknown slash reason]", fmt.Errorf("unknown slash reason: %d", s) } @@ -84,6 +99,10 @@ func (s *SlashReason) UnmarshalText(text []byte) error { *s = SlashBeaconNonparticipation case SlashConsensusLightClientAttackName: *s = SlashConsensusLightClientAttack + case SlashRuntimeIncorrectResultsName: + *s = SlashRuntimeIncorrectResults + case SlashRuntimeEquivocationName: + *s = SlashRuntimeEquivocation default: return fmt.Errorf("invalid slash reason: %s", string(text)) } diff --git a/go/staking/api/slashing_test.go b/go/staking/api/slashing_test.go index 348b089b7fc..28ff3b6a4a2 100644 --- a/go/staking/api/slashing_test.go +++ b/go/staking/api/slashing_test.go @@ -12,6 +12,8 @@ func TestSlashReason(t *testing.T) { // Test valid SlashReasons. for _, k := range []SlashReason{ SlashConsensusEquivocation, + SlashRuntimeIncorrectResults, + SlashRuntimeEquivocation, } { enc, err := k.MarshalText() require.NoError(err, "MarshalText") diff --git a/go/staking/tests/tester.go b/go/staking/tests/tester.go index 0a9dbf89274..2dc754b832c 100644 --- a/go/staking/tests/tester.go +++ b/go/staking/tests/tester.go @@ -73,7 +73,7 @@ var ( qtyOne = *quantity.NewFromUint64(1) - srcSigner = debug.DebugStateSrcSigner + SrcSigner = debug.DebugStateSrcSigner SrcAddr = debug.DebugStateSrcAddress destSigner = debug.DebugStateDestSigner DestAddr = debug.DebugStateDestAddress @@ -208,7 +208,7 @@ func testTransfer(t *testing.T, state *stakingTestsState, backend api.Backend, c Amount: *quantity.NewFromUint64(math.MaxUint8), } tx := api.NewTransferTx(srcAcc.General.Nonce, nil, xfer) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "Transfer") var gotTransfer bool @@ -276,7 +276,7 @@ TransferWaitLoop: xfer.Amount = newSrcAcc.General.Balance tx = api.NewTransferTx(newSrcAcc.General.Nonce, nil, xfer) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.Error(err, "Transfer - more than available balance") } @@ -295,7 +295,7 @@ func testSelfTransfer(t *testing.T, state *stakingTestsState, backend api.Backen Amount: *quantity.NewFromUint64(math.MaxUint8), } tx := api.NewTransferTx(srcAcc.General.Nonce, nil, xfer) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "Transfer") var gotTransfer bool @@ -343,7 +343,7 @@ TransferWaitLoop: xfer.Amount = newSrcAcc.General.Balance tx = api.NewTransferTx(newSrcAcc.General.Nonce, nil, xfer) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.Error(err, "Transfer - more than available balance") } @@ -364,7 +364,7 @@ func testBurn(t *testing.T, state *stakingTestsState, backend api.Backend, conse Amount: *quantity.NewFromUint64(math.MaxUint32), } tx := api.NewBurnTx(srcAcc.General.Nonce, nil, burn) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "Burn") select { @@ -407,11 +407,11 @@ func testBurn(t *testing.T, state *stakingTestsState, backend api.Backend, conse } func testEscrow(t *testing.T, state *stakingTestsState, backend api.Backend, consensus consensusAPI.Backend) { - testEscrowEx(t, state, backend, consensus, SrcAddr, srcSigner, DestAddr) + testEscrowEx(t, state, backend, consensus, SrcAddr, SrcSigner, DestAddr) } func testSelfEscrow(t *testing.T, state *stakingTestsState, backend api.Backend, consensus consensusAPI.Backend) { - testEscrowEx(t, state, backend, consensus, SrcAddr, srcSigner, SrcAddr) + testEscrowEx(t, state, backend, consensus, SrcAddr, SrcSigner, SrcAddr) } func testEscrowEx( // nolint: gocyclo @@ -420,7 +420,7 @@ func testEscrowEx( // nolint: gocyclo backend api.Backend, consensus consensusAPI.Backend, srcAddr api.Address, - srcSigner signature.Signer, + SrcSigner signature.Signer, dstAddr api.Address, ) { require := require.New(t) @@ -454,7 +454,7 @@ func testEscrowEx( // nolint: gocyclo Amount: *quantity.NewFromUint64(math.MaxUint32), } tx := api.NewAddEscrowTx(srcAcc.General.Nonce, nil, escrow) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "AddEscrow") require.NoError(totalEscrowed.Add(&escrow.Amount)) @@ -521,7 +521,7 @@ func testEscrowEx( // nolint: gocyclo Amount: *quantity.NewFromUint64(math.MaxUint32), } tx = api.NewAddEscrowTx(srcAcc.General.Nonce, nil, escrow) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "AddEscrow") require.NoError(totalEscrowed.Add(&escrow.Amount)) @@ -592,7 +592,7 @@ func testEscrowEx( // nolint: gocyclo Shares: dstAcc.Escrow.Active.TotalShares, } tx = api.NewReclaimEscrowTx(srcAcc.General.Nonce, nil, reclaim) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "ReclaimEscrow") // Query debonding delegations. @@ -666,7 +666,7 @@ func testEscrowEx( // nolint: gocyclo Shares: reclaim.Shares, } tx = api.NewReclaimEscrowTx(newSrcAcc.General.Nonce, nil, reclaim) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.Error(err, "ReclaimEscrow") debs, err = backend.DebondingDelegations(context.Background(), &api.OwnerQuery{Owner: srcAddr, Height: consensusAPI.HeightLatest}) @@ -679,7 +679,7 @@ func testEscrowEx( // nolint: gocyclo Amount: *quantity.NewFromUint64(1), // Minimum is 10. } tx = api.NewAddEscrowTx(srcAcc.General.Nonce, nil, escrow) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.Error(err, "AddEscrow") } @@ -701,7 +701,7 @@ func testAllowance(t *testing.T, state *stakingTestsState, backend api.Backend, AmountChange: *quantity.NewFromUint64(math.MaxUint8), } tx := api.NewAllowTx(srcAcc.General.Nonce, nil, allow) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "Allow") // Compute what new the total expected allowance should be. @@ -846,7 +846,7 @@ func testSlashConsensusEquivocation( Amount: *quantity.NewFromUint64(math.MaxUint32), } tx := api.NewAddEscrowTx(srcAcc.General.Nonce, nil, escrow) - err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, srcSigner, tx) + err = consensusAPI.SignAndSubmitTx(context.Background(), consensus, SrcSigner, tx) require.NoError(err, "AddEscrow") // Query updated validator account state.