From c4d3bdd4315dc3988fb9b21d9416dd382db548cb Mon Sep 17 00:00:00 2001 From: Jernej Kos Date: Tue, 9 Jun 2020 08:54:02 +0200 Subject: [PATCH] registry: Add runtime-specific staking thresholds --- .changelog/2995.breaking.md | 1 + docs/consensus/registry.md | 14 +- go/common/quantity/quantity.go | 9 + .../tendermint/apps/registry/transactions.go | 11 +- .../apps/registry/transactions_test.go | 282 ++++++++++++++++++ .../apps/staking/state/accumulator.go | 4 +- .../apps/staking/state/accumulator_test.go | 4 +- go/oasis-node/cmd/registry/runtime/runtime.go | 37 ++- go/oasis-test-runner/oasis/cli/registry.go | 9 + go/oasis-test-runner/oasis/fixture.go | 4 +- go/oasis-test-runner/oasis/runtime.go | 12 + .../scenario/e2e/registry_cli.go | 12 +- go/registry/api/api.go | 44 ++- go/registry/api/runtime.go | 45 ++- go/registry/api/sanity_check.go | 14 +- go/registry/tests/tester.go | 189 ++++++++---- go/staking/api/api.go | 141 +++++++-- go/staking/api/api_test.go | 130 ++++++-- go/upgrade/migrations/dummy.go | 6 +- tests/fixture-data/consim/genesis.json | 14 +- .../fixture-data/debond/staking-genesis.json | 14 +- tests/fixture-data/net-runner/default.json | 2 + .../runtime-dynamic/staking-genesis.json | 14 +- .../stake-cli/staking-genesis.json | 14 +- 24 files changed, 867 insertions(+), 159 deletions(-) create mode 100644 .changelog/2995.breaking.md create mode 100644 go/consensus/tendermint/apps/registry/transactions_test.go diff --git a/.changelog/2995.breaking.md b/.changelog/2995.breaking.md new file mode 100644 index 00000000000..8fe38af3356 --- /dev/null +++ b/.changelog/2995.breaking.md @@ -0,0 +1 @@ +registry: Add runtime-specific staking thresholds diff --git a/docs/consensus/registry.md b/docs/consensus/registry.md index 0d2a68c489d..eee4004548a 100644 --- a/docs/consensus/registry.md +++ b/docs/consensus/registry.md @@ -138,8 +138,17 @@ The node descriptor structure MUST be signed by all of the following keys: * P2P key. Registering a node may require sufficient stake in the owning entity's -[escrow account]. The exact stake threshold is a consensus parameter (see -[`Thresholds` in staking consensus parameters]). +[escrow account]. There are two kinds of thresholds that the node may need to +satisfy: + +* Global thresholds are the same for all runtimes and are defined by the + consensus parameters (see [`Thresholds` in staking consensus parameters]). + +* In _addition_ to the global thresholds, each runtime the node is registering + for may define their own thresholds. In case the node is registering for + multiple runtimes, it needs to satisfy the maximum threshold of all the + runtimes it is registering for. The runtime-specific thresholds are defined + in the [`Staking` field] in the runtime descriptor. [`NewRegisterNodeTx`]: https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/registry/api?tab=doc#NewRegisterNodeTx @@ -147,6 +156,7 @@ Registering a node may require sufficient stake in the owning entity's [`Node`]: https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/common/node?tab=doc#Node [multi-signed envelope]: ../crypto.md#multi-signed-envelope [`Thresholds` in staking consensus parameters]: https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/staking/api?tab=doc#ConsensusParameters.Thresholds +[`Staking` field]: https://pkg.go.dev/github.com/oasisprotocol/oasis-core/go/registry/api?tab=doc#Runtime.Staking ### Unfreeze Node diff --git a/go/common/quantity/quantity.go b/go/common/quantity/quantity.go index e460b3cee42..70e47d1a856 100644 --- a/go/common/quantity/quantity.go +++ b/go/common/quantity/quantity.go @@ -207,6 +207,15 @@ func NewQuantity() (q *Quantity) { return &Quantity{} } +// NewFromUint64 creates a new Quantity from an uint64 or panics. +func NewFromUint64(n uint64) *Quantity { + var q Quantity + if err := q.FromUint64(n); err != nil { + panic(err) + } + return &q +} + func isValid(n *big.Int) bool { return n.Cmp(&zero) >= 0 } diff --git a/go/consensus/tendermint/apps/registry/transactions.go b/go/consensus/tendermint/apps/registry/transactions.go index 71c45488a01..faee6c113d8 100644 --- a/go/consensus/tendermint/apps/registry/transactions.go +++ b/go/consensus/tendermint/apps/registry/transactions.go @@ -51,8 +51,13 @@ func (app *registryApplication) registerEntity( if !params.DebugBypassStake { acctAddr := staking.NewAddress(ent.ID) - if err = stakingState.AddStakeClaim(ctx, acctAddr, registry.StakeClaimRegisterEntity, []staking.ThresholdKind{staking.KindEntity}); err != nil { - ctx.Logger().Error("RegisterEntity: Insufficient stake", + if err = stakingState.AddStakeClaim( + ctx, + acctAddr, + registry.StakeClaimRegisterEntity, + staking.GlobalStakeThresholds(staking.KindEntity), + ); err != nil { + ctx.Logger().Error("RegisterEntity: Insufficent stake", "err", err, "entity", ent.ID, "account", acctAddr, @@ -309,7 +314,7 @@ func (app *registryApplication) registerNode( // nolint: gocyclo } claim := registry.StakeClaimForNode(newNode.ID) - thresholds := registry.StakeThresholdsForNode(newNode) + thresholds := registry.StakeThresholdsForNode(newNode, paidRuntimes) acctAddr := staking.NewAddress(newNode.EntityID) if err = stakeAcc.AddStakeClaim(acctAddr, claim, thresholds); err != nil { diff --git a/go/consensus/tendermint/apps/registry/transactions_test.go b/go/consensus/tendermint/apps/registry/transactions_test.go new file mode 100644 index 00000000000..c3aebdbe4f9 --- /dev/null +++ b/go/consensus/tendermint/apps/registry/transactions_test.go @@ -0,0 +1,282 @@ +package registry + +import ( + "testing" + "time" + + requirePkg "github.com/stretchr/testify/require" + + "github.com/oasisprotocol/oasis-core/go/common" + "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 TestRegisterNode(t *testing.T) { + require := requirePkg.New(t) + + now := time.Unix(1580461674, 0) + appState := abciAPI.NewMockApplicationState(abciAPI.MockApplicationStateConfig{}) + ctx := appState.NewContext(abciAPI.ContextDeliverTx, now) + defer ctx.Close() + + app := registryApplication{appState} + state := registryState.NewMutableState(ctx.State()) + stakeState := stakingState.NewMutableState(ctx.State()) + + // Set up default staking consensus parameters. + defaultStakeParameters := staking.ConsensusParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindEntity: *quantity.NewFromUint64(0), + staking.KindNodeValidator: *quantity.NewFromUint64(0), + staking.KindNodeCompute: *quantity.NewFromUint64(0), + staking.KindNodeStorage: *quantity.NewFromUint64(0), + staking.KindNodeKeyManager: *quantity.NewFromUint64(0), + staking.KindRuntimeCompute: *quantity.NewFromUint64(0), + staking.KindRuntimeKeyManager: *quantity.NewFromUint64(0), + }, + } + // Set up registry consensus parameters. + err := state.SetConsensusParameters(ctx, ®istry.ConsensusParameters{ + MaxNodeExpiration: 5, + }) + require.NoError(err, "registry.SetConsensusParameters") + + tcs := []struct { + name string + prepareFn func(n *node.Node) []signature.Signer + stakeParams *staking.ConsensusParameters + valid bool + }{ + // Node without any roles. + {"WithoutRoles", nil, nil, false}, + // A simple validator node. + { + "Validator", + func(n *node.Node) []signature.Signer { + n.AddRoles(node.RoleValidator) + return nil + }, + nil, + true, + }, + // An expired validator node. + { + "ExpiredValidator", + func(n *node.Node) []signature.Signer { + n.AddRoles(node.RoleValidator) + n.Expiration = 0 + return nil + }, + nil, + false, + }, + // Validator without enough stake. + { + "ValidatorWithoutStake", + func(n *node.Node) []signature.Signer { + n.AddRoles(node.RoleValidator) + return nil + }, + &staking.ConsensusParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindEntity: *quantity.NewFromUint64(0), + staking.KindNodeValidator: *quantity.NewFromUint64(1000), + staking.KindNodeCompute: *quantity.NewFromUint64(0), + staking.KindNodeStorage: *quantity.NewFromUint64(0), + staking.KindNodeKeyManager: *quantity.NewFromUint64(0), + staking.KindRuntimeCompute: *quantity.NewFromUint64(0), + staking.KindRuntimeKeyManager: *quantity.NewFromUint64(0), + }, + }, + false, + }, + // Compute node. + { + "ComputeNode", + func(n *node.Node) []signature.Signer { + // Create a new runtime. + rtSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: runtime signer: ComputeNode") + rt := registry.Runtime{ + DescriptorVersion: registry.LatestRuntimeDescriptorVersion, + ID: common.NewTestNamespaceFromSeed([]byte("consensus/tendermint/apps/registry: runtime: ComputeNode"), 0), + Kind: registry.KindCompute, + } + sigRt, _ := registry.SignRuntime(rtSigner, registry.RegisterRuntimeSignatureContext, &rt) + _ = state.SetRuntime(ctx, &rt, sigRt, false) + + n.AddRoles(node.RoleComputeWorker) + n.Runtimes = []*node.Runtime{ + &node.Runtime{ID: rt.ID}, + } + return nil + }, + nil, + true, + }, + // Compute node without per-runtime stake. + { + "ComputeNodeWithoutPerRuntimeStake", + func(n *node.Node) []signature.Signer { + // Create a new runtime. + rtSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: runtime signer: ComputeNodeWithoutPerRuntimeStake") + rt := registry.Runtime{ + DescriptorVersion: registry.LatestRuntimeDescriptorVersion, + ID: common.NewTestNamespaceFromSeed([]byte("consensus/tendermint/apps/registry: runtime: ComputeNodeWithoutPerRuntimeStake"), 0), + Kind: registry.KindCompute, + Staking: registry.RuntimeStakingParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindNodeCompute: *quantity.NewFromUint64(1000), + }, + }, + } + sigRt, _ := registry.SignRuntime(rtSigner, registry.RegisterRuntimeSignatureContext, &rt) + _ = state.SetRuntime(ctx, &rt, sigRt, false) + + n.AddRoles(node.RoleComputeWorker) + n.Runtimes = []*node.Runtime{ + &node.Runtime{ID: rt.ID}, + } + return nil + }, + nil, + false, + }, + // Compute node with ehough per-runtime stake. + { + "ComputeNodeWithPerRuntimeStake", + func(n *node.Node) []signature.Signer { + // Create a new runtime. + rtSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: runtime signer: ComputeNodeWithPerRuntimeStake") + rt := registry.Runtime{ + DescriptorVersion: registry.LatestRuntimeDescriptorVersion, + ID: common.NewTestNamespaceFromSeed([]byte("consensus/tendermint/apps/registry: runtime: ComputeNodeWithPerRuntimeStake"), 0), + Kind: registry.KindCompute, + Staking: registry.RuntimeStakingParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindNodeCompute: *quantity.NewFromUint64(1000), + }, + }, + } + sigRt, _ := registry.SignRuntime(rtSigner, registry.RegisterRuntimeSignatureContext, &rt) + _ = state.SetRuntime(ctx, &rt, sigRt, false) + + // Add bonded stake (hacky, without a self-delegation). + _ = stakeState.SetAccount(ctx, staking.NewAddress(n.EntityID), &staking.Account{ + Escrow: staking.EscrowAccount{ + Active: staking.SharePool{ + Balance: *quantity.NewFromUint64(10_000), + }, + }, + }) + + n.AddRoles(node.RoleComputeWorker) + n.Runtimes = []*node.Runtime{ + &node.Runtime{ID: rt.ID}, + } + return nil + }, + nil, + true, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + require = requirePkg.New(t) + + // Reset staking consensus parameters. + stakeParams := tc.stakeParams + if stakeParams == nil { + stakeParams = &defaultStakeParameters + } + err = stakeState.SetConsensusParameters(ctx, stakeParams) + require.NoError(err, "staking.SetConsensusParameters") + + // Prepare default signers. + entitySigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: entity signer: " + tc.name) + nodeSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: node signer: " + tc.name) + consensusSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: consensus signer: " + tc.name) + p2pSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: p2p signer: " + tc.name) + tlsSigner := memorySigner.NewTestSigner("consensus/tendermint/apps/registry: tls signer: " + tc.name) + + // Prepare a test entity that owns the nodes. + ent := entity.Entity{ + DescriptorVersion: entity.LatestEntityDescriptorVersion, + ID: entitySigner.Public(), + Nodes: []signature.PublicKey{nodeSigner.Public()}, + } + sigEnt, err := entity.SignEntity(entitySigner, registry.RegisterEntitySignatureContext, &ent) + require.NoError(err, "SignEntity") + err = state.SetEntity(ctx, &ent, sigEnt) + require.NoError(err, "SetEntity") + + // Prepare a new minimal node. + var address node.Address + err = address.UnmarshalText([]byte("8.8.8.8:1234")) + require.NoError(err, "address.UnmarshalText") + + n := node.Node{ + DescriptorVersion: node.LatestNodeDescriptorVersion, + ID: nodeSigner.Public(), + EntityID: ent.ID, + Expiration: 3, + P2P: node.P2PInfo{ + ID: p2pSigner.Public(), + Addresses: []node.Address{address}, + }, + Consensus: node.ConsensusInfo{ + ID: consensusSigner.Public(), + Addresses: []node.ConsensusAddress{ + {ID: consensusSigner.Public(), Address: address}, + }, + }, + TLS: node.TLSInfo{ + PubKey: tlsSigner.Public(), + Addresses: []node.TLSAddress{ + {PubKey: tlsSigner.Public(), Address: address}, + }, + }, + } + var signers []signature.Signer + if tc.prepareFn != nil { + signers = tc.prepareFn(&n) + } + if signers == nil { + signers = []signature.Signer{nodeSigner, p2pSigner, consensusSigner, tlsSigner} + } + + // Sign the node. + sigNode, err := node.MultiSignNode(signers, registry.RegisterNodeSignatureContext, &n) + require.NoError(err, "MultiSignNode") + + // Attempt to register the node. + ctx.SetTxSigner(nodeSigner.Public()) + err = app.registerNode(ctx, state, sigNode) + switch tc.valid { + case true: + require.NoError(err, "node registration should succeed") + + // Make sure the node has been registered. + var regNode *node.Node + regNode, err = state.Node(ctx, n.ID) + require.NoError(err, "node should be registered") + require.EqualValues(&n, regNode, "registered node descriptor should be correct") + case false: + require.Error(err, "node registration should fail") + + // Make sure the state has not changed. + _, err = state.Node(ctx, n.ID) + require.Error(err, "node should not be registered") + require.Equal(registry.ErrNoSuchNode, err) + } + }) + } +} diff --git a/go/consensus/tendermint/apps/staking/state/accumulator.go b/go/consensus/tendermint/apps/staking/state/accumulator.go index f99b9395569..7a77e6639ff 100644 --- a/go/consensus/tendermint/apps/staking/state/accumulator.go +++ b/go/consensus/tendermint/apps/staking/state/accumulator.go @@ -56,7 +56,7 @@ func (c *StakeAccumulatorCache) CheckStakeClaims(addr staking.Address) error { func (c *StakeAccumulatorCache) AddStakeClaim( addr staking.Address, claim staking.StakeClaim, - thresholds []staking.ThresholdKind, + thresholds []staking.StakeThreshold, ) error { acct, err := c.getAccount(addr) if err != nil { @@ -128,7 +128,7 @@ func AddStakeClaim( ctx *abciAPI.Context, addr staking.Address, claim staking.StakeClaim, - thresholds []staking.ThresholdKind, + thresholds []staking.StakeThreshold, ) error { sa, err := NewStakeAccumulatorCache(ctx) if err != nil { diff --git a/go/consensus/tendermint/apps/staking/state/accumulator_test.go b/go/consensus/tendermint/apps/staking/state/accumulator_test.go index 62830c9e876..b6f7e28e3db 100644 --- a/go/consensus/tendermint/apps/staking/state/accumulator_test.go +++ b/go/consensus/tendermint/apps/staking/state/accumulator_test.go @@ -47,7 +47,7 @@ func TestStakeAccumulatorCache(t *testing.T) { err = stakeState.SetAccount(ctx, addr, &acct) require.NoError(err, "SetAccount") - err = acc.AddStakeClaim(addr, staking.StakeClaim("claim"), []staking.ThresholdKind{staking.KindEntity}) + err = acc.AddStakeClaim(addr, staking.StakeClaim("claim"), staking.GlobalStakeThresholds(staking.KindEntity)) require.NoError(err, "AddStakeClaim") err = acc.CheckStakeClaims(addr) @@ -83,7 +83,7 @@ func TestStakeAccumulatorCache(t *testing.T) { require.Len(acct2.Escrow.StakeAccumulator.Claims, 1, "claims should not be updated") // Test convenience functions. - err = AddStakeClaim(ctx, addr, staking.StakeClaim("claim"), []staking.ThresholdKind{staking.KindEntity}) + err = AddStakeClaim(ctx, addr, staking.StakeClaim("claim"), staking.GlobalStakeThresholds(staking.KindEntity)) require.NoError(err, "AddStakeClaim") err = RemoveStakeClaim(ctx, addr, staking.StakeClaim("claim")) require.NoError(err, "RemoveStakeClaim") diff --git a/go/oasis-node/cmd/registry/runtime/runtime.go b/go/oasis-node/cmd/registry/runtime/runtime.go index b8d7235454e..ae9b1936576 100644 --- a/go/oasis-node/cmd/registry/runtime/runtime.go +++ b/go/oasis-node/cmd/registry/runtime/runtime.go @@ -21,6 +21,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/logging" "github.com/oasisprotocol/oasis-core/go/common/node" + "github.com/oasisprotocol/oasis-core/go/common/quantity" "github.com/oasisprotocol/oasis-core/go/common/sgx" "github.com/oasisprotocol/oasis-core/go/common/version" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" @@ -30,6 +31,7 @@ import ( cmdGrpc "github.com/oasisprotocol/oasis-core/go/oasis-node/cmd/common/grpc" cmdSigner "github.com/oasisprotocol/oasis-core/go/oasis-node/cmd/common/signer" registry "github.com/oasisprotocol/oasis-core/go/registry/api" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" storage "github.com/oasisprotocol/oasis-core/go/storage/api" "github.com/oasisprotocol/oasis-core/go/storage/mkvs" ) @@ -80,6 +82,9 @@ const ( AdmissionPolicyNameAnyNode = "any-node" AdmissionPolicyNameEntityWhitelist = "entity-whitelist" + // Staking parameters flags. + CfgStakingThreshold = "runtime.staking.threshold" + runtimeGenesisFilename = "runtime_genesis.json" ) @@ -224,7 +229,7 @@ func doList(cmd *cobra.Command, args []string) { } } -func runtimeFromFlags() (*registry.Runtime, signature.Signer, error) { +func runtimeFromFlags() (*registry.Runtime, signature.Signer, error) { // nolint: gocyclo var id common.Namespace if err := id.UnmarshalHex(viper.GetString(CfgID)); err != nil { logger.Error("failed to parse runtime ID", @@ -433,6 +438,33 @@ func runtimeFromFlags() (*registry.Runtime, signature.Signer, error) { return nil, nil, fmt.Errorf("invalid runtime admission policy") } + // Staking parameters. + if th := viper.GetStringMapString(CfgStakingThreshold); th != nil { + rt.Staking.Thresholds = make(map[staking.ThresholdKind]quantity.Quantity) + for kindRaw, valueRaw := range th { + var ( + kind staking.ThresholdKind + value quantity.Quantity + ) + + if err = kind.UnmarshalText([]byte(kindRaw)); err != nil { + return nil, nil, fmt.Errorf("staking: bad threshold kind (%s): %w", kindRaw, err) + } + if err = value.UnmarshalText([]byte(valueRaw)); err != nil { + return nil, nil, fmt.Errorf("staking: bad threshold value (%s): %w", valueRaw, err) + } + + if _, ok := rt.Staking.Thresholds[kind]; ok { + return nil, nil, fmt.Errorf("staking: duplicate value for threshold '%s'", kind) + } + rt.Staking.Thresholds[kind] = value + } + } + + // Validate descriptor. + if err = rt.ValidateBasic(true); err != nil { + return nil, nil, fmt.Errorf("invalid runtime descriptor: %w", err) + } // Validate storage configuration. if err = registry.VerifyRegisterRuntimeStorageArgs(rt, logger); err != nil { return nil, nil, fmt.Errorf("invalid runtime storage configuration: %w", err) @@ -537,6 +569,9 @@ func init() { runtimeFlags.String(CfgAdmissionPolicy, "", "What type of node admission policy to have") runtimeFlags.StringSlice(CfgAdmissionPolicyEntityWhitelist, nil, "For entity whitelist node admission policies, the IDs (hex) of the entities in the whitelist") + // Init Staking flags. + runtimeFlags.StringToString(CfgStakingThreshold, nil, "Additional staking threshold for this runtime (=)") + _ = viper.BindPFlags(runtimeFlags) runtimeFlags.AddFlagSet(cmdSigner.Flags) runtimeFlags.AddFlagSet(cmdSigner.CLIFlags) diff --git a/go/oasis-test-runner/oasis/cli/registry.go b/go/oasis-test-runner/oasis/cli/registry.go index 70bd4dd91da..50713327ca9 100644 --- a/go/oasis-test-runner/oasis/cli/registry.go +++ b/go/oasis-test-runner/oasis/cli/registry.go @@ -108,6 +108,15 @@ func (r *RegistryHelpers) GenerateRegisterRuntimeTx( return fmt.Errorf("invalid admission policy") } + for kind, value := range runtime.Staking.Thresholds { + kindRaw, _ := kind.MarshalText() + valueRaw, _ := value.MarshalText() + + args = append(args, + "--"+cmdRegRt.CfgStakingThreshold, fmt.Sprintf("%s=%s", string(kindRaw), string(valueRaw)), + ) + } + if out, err := r.runSubCommandWithOutput("registry-runtime-gen_register", args); err != nil { return fmt.Errorf("failed to generate register runtime tx: error: %w output: %s", err, out.String()) } diff --git a/go/oasis-test-runner/oasis/fixture.go b/go/oasis-test-runner/oasis/fixture.go index 7317c925a00..9c15c2aac00 100644 --- a/go/oasis-test-runner/oasis/fixture.go +++ b/go/oasis-test-runner/oasis/fixture.go @@ -197,7 +197,8 @@ type RuntimeFixture struct { // nolint: maligned TxnScheduler registry.TxnSchedulerParameters `json:"txn_scheduler"` Storage registry.StorageParameters `json:"storage"` - AdmissionPolicy registry.RuntimeAdmissionPolicy `json:"admission_policy"` + AdmissionPolicy registry.RuntimeAdmissionPolicy `json:"admission_policy"` + Staking registry.RuntimeStakingParameters `json:"staking,omitempty"` Pruner RuntimePrunerCfg `json:"pruner,omitempty"` @@ -235,6 +236,7 @@ func (f *RuntimeFixture) Create(netFixture *NetworkFixture, net *Network) (*Runt TxnScheduler: f.TxnScheduler, Storage: f.Storage, AdmissionPolicy: f.AdmissionPolicy, + Staking: f.Staking, Binaries: f.Binaries, GenesisState: f.GenesisState, GenesisRound: f.GenesisRound, diff --git a/go/oasis-test-runner/oasis/runtime.go b/go/oasis-test-runner/oasis/runtime.go index c3604df289b..3166ab3917b 100644 --- a/go/oasis-test-runner/oasis/runtime.go +++ b/go/oasis-test-runner/oasis/runtime.go @@ -57,6 +57,7 @@ type RuntimeCfg struct { // nolint: maligned Storage registry.StorageParameters AdmissionPolicy registry.RuntimeAdmissionPolicy + Staking registry.RuntimeStakingParameters Pruner RuntimePrunerCfg @@ -125,6 +126,7 @@ func (net *Network) NewRuntime(cfg *RuntimeCfg) (*Runtime, error) { TxnScheduler: cfg.TxnScheduler, Storage: cfg.Storage, AdmissionPolicy: cfg.AdmissionPolicy, + Staking: cfg.Staking, } rtDir, err := net.baseDir.NewSubDir("runtime-" + cfg.ID.String()) @@ -221,6 +223,16 @@ func (net *Network) NewRuntime(cfg *RuntimeCfg) (*Runtime, error) { } else { return nil, fmt.Errorf("invalid admission policy") } + + for kind, value := range cfg.Staking.Thresholds { + kindRaw, _ := kind.MarshalText() + valueRaw, _ := value.MarshalText() + + args = append(args, + "--"+cmdRegRt.CfgStakingThreshold, fmt.Sprintf("%s=%s", string(kindRaw), string(valueRaw)), + ) + } + args = append(args, cfg.Entity.toGenesisArgs()...) w, err := rtDir.NewLogWriter("provision.log") diff --git a/go/oasis-test-runner/scenario/e2e/registry_cli.go b/go/oasis-test-runner/scenario/e2e/registry_cli.go index c1e06fe7257..7b35e28caa0 100644 --- a/go/oasis-test-runner/scenario/e2e/registry_cli.go +++ b/go/oasis-test-runner/scenario/e2e/registry_cli.go @@ -19,6 +19,7 @@ import ( fileSigner "github.com/oasisprotocol/oasis-core/go/common/crypto/signature/signers/file" "github.com/oasisprotocol/oasis-core/go/common/entity" "github.com/oasisprotocol/oasis-core/go/common/node" + "github.com/oasisprotocol/oasis-core/go/common/quantity" cmdCommon "github.com/oasisprotocol/oasis-core/go/oasis-node/cmd/common" "github.com/oasisprotocol/oasis-core/go/oasis-node/cmd/common/consensus" "github.com/oasisprotocol/oasis-core/go/oasis-node/cmd/common/flags" @@ -31,10 +32,11 @@ import ( "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/oasis/cli" "github.com/oasisprotocol/oasis-core/go/oasis-test-runner/scenario" registry "github.com/oasisprotocol/oasis-core/go/registry/api" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" ) var ( - // RegistryCLI is the staking scenario. + // RegistryCLI is the registry CLI test scenario. RegistryCLI scenario.Scenario = ®istryCLIImpl{ runtimeImpl: *newRuntimeImpl("registry-cli", "", nil), } @@ -605,6 +607,8 @@ func (r *registryCLIImpl) testRuntime(childEnv *env.Env, cli *cli.Helpers) error if err != nil { return fmt.Errorf("TestEntity: %w", err) } + var q quantity.Quantity + _ = q.FromUint64(100) testRuntime := registry.Runtime{ DescriptorVersion: registry.LatestRuntimeDescriptorVersion, EntityID: testEntity.ID, @@ -642,6 +646,12 @@ func (r *registryCLIImpl) testRuntime(childEnv *env.Env, cli *cli.Helpers) error }, }, }, + Staking: registry.RuntimeStakingParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindNodeCompute: q, + staking.KindNodeStorage: q, + }, + }, } // Runtime ID 0x0 is for simple-keyvalue, 0xf... is for the keymanager. Let's use 0x1. _ = testRuntime.ID.UnmarshalHex("8000000000000000000000000000000000000000000000000000000000000001") diff --git a/go/registry/api/api.go b/go/registry/api/api.go index 537972f690d..13208d793ba 100644 --- a/go/registry/api/api.go +++ b/go/registry/api/api.go @@ -17,6 +17,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/logging" "github.com/oasisprotocol/oasis-core/go/common/node" "github.com/oasisprotocol/oasis-core/go/common/pubsub" + "github.com/oasisprotocol/oasis-core/go/common/quantity" "github.com/oasisprotocol/oasis-core/go/common/sgx/ias" "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" epochtime "github.com/oasisprotocol/oasis-core/go/epochtime/api" @@ -1353,29 +1354,56 @@ func StakeClaimForRuntime(id common.Namespace) staking.StakeClaim { } // StakeThresholdsForNode returns the staking thresholds for the given node. -func StakeThresholdsForNode(n *node.Node) (thresholds []staking.ThresholdKind) { +// +// The passed list of runtimes must be runtime descriptors for all runtimes that the node is +// registered for in the same order as they appear in the node descriptor (for example as returned +// by the VerifyRegisterNodeArgs function). +func StakeThresholdsForNode(n *node.Node, rts []*Runtime) (thresholds []staking.StakeThreshold) { + var rtKinds []staking.ThresholdKind if n.HasRoles(node.RoleKeyManager) { - thresholds = append(thresholds, staking.KindNodeKeyManager) + thresholds = append(thresholds, staking.GlobalStakeThreshold(staking.KindNodeKeyManager)) + rtKinds = append(rtKinds, staking.KindNodeKeyManager) } if n.HasRoles(node.RoleComputeWorker) { - thresholds = append(thresholds, staking.KindNodeCompute) + thresholds = append(thresholds, staking.GlobalStakeThreshold(staking.KindNodeCompute)) + rtKinds = append(rtKinds, staking.KindNodeCompute) } if n.HasRoles(node.RoleStorageWorker) { - thresholds = append(thresholds, staking.KindNodeStorage) + thresholds = append(thresholds, staking.GlobalStakeThreshold(staking.KindNodeStorage)) + rtKinds = append(rtKinds, staking.KindNodeStorage) } if n.HasRoles(node.RoleValidator) { - thresholds = append(thresholds, staking.KindNodeValidator) + thresholds = append(thresholds, staking.GlobalStakeThreshold(staking.KindNodeValidator)) } + + // Determine the maximum runtime-specific thresholds (if any) and add them to the returned + // stake thresholds. + maxThresholds := make(map[staking.ThresholdKind]quantity.Quantity) + for i, rt := range rts { + if !n.Runtimes[i].ID.Equal(&rt.ID) { + panic(fmt.Errorf("registry: mismatched runtime order")) + } + + for _, k := range rtKinds { + if v, q := rt.Staking.Thresholds[k], maxThresholds[k]; v.Cmp(&q) > 0 { + maxThresholds[k] = v + } + } + } + for _, t := range maxThresholds { + thresholds = append(thresholds, staking.StakeThreshold{Constant: t.Clone()}) + } + return } // StakeThresholdsForRuntime returns the staking thresholds for the given runtime. -func StakeThresholdsForRuntime(rt *Runtime) (thresholds []staking.ThresholdKind) { +func StakeThresholdsForRuntime(rt *Runtime) (thresholds []staking.StakeThreshold) { switch rt.Kind { case KindCompute: - thresholds = append(thresholds, staking.KindRuntimeCompute) + thresholds = append(thresholds, staking.GlobalStakeThreshold(staking.KindRuntimeCompute)) case KindKeyManager: - thresholds = append(thresholds, staking.KindRuntimeKeyManager) + thresholds = append(thresholds, staking.GlobalStakeThreshold(staking.KindRuntimeKeyManager)) default: panic(fmt.Errorf("registry: unknown runtime kind: %s", rt.Kind)) } diff --git a/go/registry/api/runtime.go b/go/registry/api/runtime.go index f70c3ce8ac9..a869224a80e 100644 --- a/go/registry/api/runtime.go +++ b/go/registry/api/runtime.go @@ -13,8 +13,10 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/node" "github.com/oasisprotocol/oasis-core/go/common/prettyprint" + "github.com/oasisprotocol/oasis-core/go/common/quantity" "github.com/oasisprotocol/oasis-core/go/common/sgx" "github.com/oasisprotocol/oasis-core/go/common/version" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" storage "github.com/oasisprotocol/oasis-core/go/storage/api" ) @@ -22,9 +24,6 @@ var ( // ErrUnsupportedRuntimeKind is the error returned when the parsed runtime // kind is malformed or unknown. ErrUnsupportedRuntimeKind = errors.New("runtime: unsupported runtime kind") - // ErrMalformedStoreID is the error returned when a storage service - // ID is malformed. - ErrMalformedStoreID = errors.New("runtime: Malformed store ID") _ prettyprint.PrettyPrinter = (*SignedRuntime)(nil) ) @@ -171,6 +170,39 @@ type RuntimeAdmissionPolicy struct { EntityWhitelist *EntityWhitelistRuntimeAdmissionPolicy `json:"entity_whitelist,omitempty"` } +// RuntimeStakingParameters are the stake-related parameters for a runtime. +type RuntimeStakingParameters struct { + // Thresholds are the minimum stake thresholds for a runtime. These per-runtime thresholds are + // in addition to the global thresholds. May be left unspecified. + // + // 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"` +} + +// ValidateBasic performs basic descriptor validity checks. +func (s *RuntimeStakingParameters) ValidateBasic(runtimeKind RuntimeKind) error { + for kind, q := range s.Thresholds { + switch kind { + case staking.KindNodeCompute, staking.KindNodeStorage: + if runtimeKind != KindCompute { + return fmt.Errorf("unsupported staking threshold kind for runtime: %s", kind) + } + case staking.KindNodeKeyManager: + if runtimeKind != KindKeyManager { + return fmt.Errorf("unsupported staking threshold kind for runtime: %s", kind) + } + default: + return fmt.Errorf("unsupported staking threshold kind for runtime: %s", kind) + } + + if !q.IsValid() { + return fmt.Errorf("invalid threshold of kind %s specified", kind) + } + } + return nil +} + const ( // LatestRuntimeDescriptorVersion is the latest entity descriptor version that should be used // for all new descriptors. Using earlier versions may be rejected. @@ -225,6 +257,9 @@ type Runtime struct { // nolint: maligned // AdmissionPolicy sets which nodes are allowed to register for this runtime. // This policy applies to all roles. AdmissionPolicy RuntimeAdmissionPolicy `json:"admission_policy"` + + // Staking stores the runtime's staking-related parameters. + Staking RuntimeStakingParameters `json:"staking,omitempty"` } // ValidateBasic performs basic descriptor validity checks. @@ -247,6 +282,10 @@ func (r *Runtime) ValidateBasic(strictVersion bool) error { ) } } + + if err := r.Staking.ValidateBasic(r.Kind); err != nil { + return fmt.Errorf("bad staking parameters: %w", err) + } return nil } diff --git a/go/registry/api/sanity_check.go b/go/registry/api/sanity_check.go index c7e99ccefdf..aa0530472a8 100644 --- a/go/registry/api/sanity_check.go +++ b/go/registry/api/sanity_check.go @@ -240,15 +240,23 @@ func SanityCheckStake( } // Add entity stake claim. - escrow.StakeAccumulator.AddClaimUnchecked(StakeClaimRegisterEntity, []staking.ThresholdKind{staking.KindEntity}) + escrow.StakeAccumulator.AddClaimUnchecked(StakeClaimRegisterEntity, staking.GlobalStakeThresholds(staking.KindEntity)) generatedEscrows[addr] = escrow } + runtimeMap := make(map[common.Namespace]*Runtime) + for _, rt := range runtimes { + runtimeMap[rt.ID] = rt + } for _, node := range nodes { + var nodeRts []*Runtime + for _, rt := range node.Runtimes { + nodeRts = append(nodeRts, runtimeMap[rt.ID]) + } // Add node stake claims. addr := staking.NewAddress(node.EntityID) - generatedEscrows[addr].StakeAccumulator.AddClaimUnchecked(StakeClaimForNode(node.ID), StakeThresholdsForNode(node)) + generatedEscrows[addr].StakeAccumulator.AddClaimUnchecked(StakeClaimForNode(node.ID), StakeThresholdsForNode(node, nodeRts)) } for _, rt := range runtimes { // Add runtime stake claims. @@ -315,7 +323,7 @@ func SanityCheckStake( } for i, expectedThreshold := range expectedThresholds { threshold := thresholds[i] - if threshold != expectedThreshold { + if !threshold.Equal(&expectedThreshold) { return fmt.Errorf("incorrect threshold in position %d for claim %s for account %s (expected: %s got: %s)", i, claim, diff --git a/go/registry/tests/tester.go b/go/registry/tests/tester.go index 4eb4f05942f..6fd32c86ea8 100644 --- a/go/registry/tests/tester.go +++ b/go/registry/tests/tester.go @@ -19,10 +19,12 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/entity" "github.com/oasisprotocol/oasis-core/go/common/identity" "github.com/oasisprotocol/oasis-core/go/common/node" + "github.com/oasisprotocol/oasis-core/go/common/quantity" consensusAPI "github.com/oasisprotocol/oasis-core/go/consensus/api" epochtime "github.com/oasisprotocol/oasis-core/go/epochtime/api" epochtimeTests "github.com/oasisprotocol/oasis-core/go/epochtime/tests" "github.com/oasisprotocol/oasis-core/go/registry/api" + staking "github.com/oasisprotocol/oasis-core/go/staking/api" ) const ( @@ -516,57 +518,145 @@ func testRegistryRuntime(t *testing.T, backend api.Backend, consensus consensusA Signer: testEntitySigner, } - // Runtime without key manager set. - rtMap := make(map[common.Namespace]*api.Runtime) - rt, err := NewTestRuntime([]byte("testRegistryRuntime"), entity, false) - require.NoError(err, "NewTestRuntime") - rtMap[rt.Runtime.ID] = rt.Runtime - - rt.MustRegister(t, backend, consensus) - - // Runtime using entity whitelist node admission policy. - rtEW, err := NewTestRuntime([]byte("testRegistryRuntimeEntityWhitelist"), entity, false) - require.NoError(err, "NewTestRuntime entity whitelist") - nodeEntities, err := NewTestEntities(entityNodeSeed, 3) - require.NoError(err, "NewTestEntities with entity node seed") - rtEW.Runtime.AdmissionPolicy = api.RuntimeAdmissionPolicy{ - EntityWhitelist: &api.EntityWhitelistRuntimeAdmissionPolicy{ - Entities: map[signature.PublicKey]bool{ - nodeEntities[1].Entity.ID: true, + // Runtime registration test cases. + rtMapByName := make(map[string]*api.Runtime) + tcs := []struct { + name string + prepareFn func(rt *api.Runtime) + keyManager bool + valid bool + }{ + // Runtime without key manager set. + {"WithoutKM", nil, false, true}, + // Runtime using entity whitelist node admission policy. + { + "EntityWhitelist", + func(rt *api.Runtime) { + var nodeEntities []*TestEntity + nodeEntities, err = NewTestEntities(entityNodeSeed, 3) + require.NoError(err, "NewTestEntities with entity node seed") + rt.AdmissionPolicy = api.RuntimeAdmissionPolicy{ + EntityWhitelist: &api.EntityWhitelistRuntimeAdmissionPolicy{ + Entities: map[signature.PublicKey]bool{ + nodeEntities[1].Entity.ID: true, + }, + }, + } + }, + false, + true, + }, + // Runtime with unset node admission policy. + { + "UnsetAdmissionPolicy", + func(rt *api.Runtime) { + rt.AdmissionPolicy = api.RuntimeAdmissionPolicy{} + }, + false, + false, + }, + // Runtime using custom staking thresholds. + { + "StakingThresholds", + func(rt *api.Runtime) { + var q quantity.Quantity + _ = q.FromUint64(1000) + + rt.Staking = api.RuntimeStakingParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindNodeCompute: q, + staking.KindNodeStorage: q, + }, + } + }, + false, + true, + }, + // Runtime using invalid custom staking thresholds. + { + "StakingThresholdsInvalid1", + func(rt *api.Runtime) { + var q quantity.Quantity + _ = q.FromUint64(1000) + + rt.Staking = api.RuntimeStakingParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindNodeCompute: q, + staking.KindNodeStorage: q, + staking.KindNodeValidator: q, + }, + } + }, + false, + false, + }, + { + "StakingThresholdsInvalid2", + func(rt *api.Runtime) { + var q quantity.Quantity + _ = q.FromUint64(1000) + + rt.Staking = api.RuntimeStakingParameters{ + Thresholds: map[staking.ThresholdKind]quantity.Quantity{ + staking.KindNodeKeyManager: q, + }, + } + }, + false, + false, + }, + // Key manager runtime. + { + "KeyManager", + func(rt *api.Runtime) { + rt.Kind = api.KindKeyManager }, + true, + true, + }, + // Runtime with key manager set. + { + "WithKM", + func(rt *api.Runtime) { + rt.KeyManager = &rtMapByName["KeyManager"].ID + }, + false, + true, + }, + // Runtime with bad key manager. + { + "WithInvalidKM", + func(rt *api.Runtime) { + rt.KeyManager = &common.Namespace{0xab} + }, + false, + false, }, } - rtMap[rtEW.Runtime.ID] = rtEW.Runtime - rtEW.MustRegister(t, backend, consensus) - - // Runtime with unset node admission policy. - rtUnsetAdmissionPolicy, err := NewTestRuntime([]byte("testRegistryRuntimeUnsetAdmissionPolicy"), entity, false) - require.NoError(err, "NewTestRuntime unset admission policy") - rtUnsetAdmissionPolicy.Runtime.AdmissionPolicy = api.RuntimeAdmissionPolicy{} - - rtUnsetAdmissionPolicy.MustNotRegister(t, backend, consensus) + rtMap := make(map[common.Namespace]*api.Runtime) + for _, tc := range tcs { + var rt *TestRuntime + rt, err = NewTestRuntime([]byte(tc.name), entity, tc.keyManager) + require.NoError(err, "NewTestRuntime (%s)", tc.name) + if tc.prepareFn != nil { + tc.prepareFn(rt.Runtime) + } - // Register key manager runtime. - km, err := NewTestRuntime([]byte("testRegistryKM"), entity, true) - km.Runtime.Kind = api.KindKeyManager - require.NoError(err, "NewTestKm") - km.MustRegister(t, backend, consensus) - rtMap[km.Runtime.ID] = km.Runtime + switch tc.valid { + case true: + rtMap[rt.Runtime.ID] = rt.Runtime + rt.MustRegister(t, backend, consensus) + case false: + rt.MustNotRegister(t, backend, consensus) + } - // Runtime with key manager set. - rtKm, err := NewTestRuntime([]byte("testRegistryRuntimeWithKM"), entity, false) - require.NoError(err, "NewTestRuntimeWithKM") - rtKm.Runtime.KeyManager = &km.Runtime.ID - rtKm.MustRegister(t, backend, consensus) - rtMap[rtKm.Runtime.ID] = rtKm.Runtime + rtMapByName[tc.name] = rt.Runtime + } registeredRuntimes, err := backend.GetRuntimes(context.Background(), consensusAPI.HeightLatest) require.NoError(err, "GetRuntimes") - // NOTE: There can be two runtimes registered here instead of one because the worker - // tests that run before this register their own runtime and this runtime - // cannot be deregistered. - require.Len(registeredRuntimes, len(existingRuntimes)+4, "registry has four new runtimes") + require.Len(registeredRuntimes, len(existingRuntimes)+len(rtMap), "registry has all the new runtimes") for _, regRuntime := range registeredRuntimes { if rtMap[regRuntime.ID] != nil { require.EqualValues(rtMap[regRuntime.ID], regRuntime, "expected runtime is registered") @@ -575,22 +665,9 @@ func testRegistryRuntime(t *testing.T, backend api.Backend, consensus consensusA } require.Len(rtMap, 0, "all runtimes were registered") - // Test runtime registration failures. - // Non-existent key manager. - rtWrongKm, err := NewTestRuntime([]byte("testRegistryRuntimeWithWrongKM"), entity, false) - require.NoError(err, "NewTestRuntimeWithWrongKM") - // Set Key manager ID to some wrong value. - rtWrongKm.Runtime.KeyManager = &common.Namespace{0xab} - - rtWrongKm.MustNotRegister(t, backend, consensus) - - registeredRuntimesAfterFailures, err := backend.GetRuntimes(context.Background(), consensusAPI.HeightLatest) - require.NoError(err, "GetRuntimes") - require.Len(registeredRuntimesAfterFailures, len(registeredRuntimes), "wrong runtimes not registered") - // No way to de-register the runtime or the controlling entity, so it will be left there. - return rt.Runtime.ID, rtEW.Runtime.ID + return rtMapByName["WithoutKM"].ID, rtMapByName["EntityWhitelist"].ID } // EnsureRegistryEmpty enforces that the registry has no entities or nodes diff --git a/go/staking/api/api.go b/go/staking/api/api.go index 7ec35a51971..28eb33bec6b 100644 --- a/go/staking/api/api.go +++ b/go/staking/api/api.go @@ -374,44 +374,143 @@ const ( KindRuntimeKeyManager ThresholdKind = 6 KindMax = KindRuntimeKeyManager + + KindEntityName = "entity" + KindNodeValidatorName = "node-validator" + KindNodeComputeName = "node-compute" + KindNodeStorageName = "node-storage" + KindNodeKeyManagerName = "node-keymanager" + KindRuntimeComputeName = "runtime-compute" + KindRuntimeKeyManagerName = "runtime-keymanager" ) // String returns the string representation of a ThresholdKind. func (k ThresholdKind) String() string { switch k { case KindEntity: - return "entity" + return KindEntityName case KindNodeValidator: - return "validator node" + return KindNodeValidatorName case KindNodeCompute: - return "compute node" + return KindNodeComputeName case KindNodeStorage: - return "storage node" + return KindNodeStorageName case KindNodeKeyManager: - return "key manager node" + return KindNodeKeyManagerName case KindRuntimeCompute: - return "compute runtime" + return KindRuntimeComputeName case KindRuntimeKeyManager: - return "key manager runtime" + return KindRuntimeKeyManagerName default: return "[unknown threshold kind]" } } +// MarshalText encodes a ThresholdKind into text form. +func (k ThresholdKind) MarshalText() ([]byte, error) { + return []byte(k.String()), nil +} + +// UnmarshalText decodes a text slice into a ThresholdKind. +func (k *ThresholdKind) UnmarshalText(text []byte) error { + switch string(text) { + case KindEntityName: + *k = KindEntity + case KindNodeValidatorName: + *k = KindNodeValidator + case KindNodeComputeName: + *k = KindNodeCompute + case KindNodeStorageName: + *k = KindNodeStorage + case KindNodeKeyManagerName: + *k = KindNodeKeyManager + case KindRuntimeComputeName: + *k = KindRuntimeCompute + case KindRuntimeKeyManagerName: + *k = KindRuntimeKeyManager + default: + return fmt.Errorf("%w: %s", ErrInvalidThreshold, string(text)) + } + return nil +} + // StakeClaim is a unique stake claim identifier. type StakeClaim string +// StakeThreshold is a stake threshold as used in the stake accumulator. +type StakeThreshold struct { + // Global is a reference to a global stake threshold. + Global *ThresholdKind `json:"global,omitempty"` + // Constant is the value for a specific threshold. + Constant *quantity.Quantity `json:"const,omitempty"` +} + +// String returns a string representation of a stake threshold. +func (st StakeThreshold) String() string { + switch { + case st.Global != nil: + return fmt.Sprintf("", *st.Global) + case st.Constant != nil: + return fmt.Sprintf("", st.Constant) + default: + return "" + } +} + +// Equal compares vs another stake threshold for equality. +func (st *StakeThreshold) Equal(cmp *StakeThreshold) bool { + if cmp == nil { + return false + } + switch { + case st.Global != nil: + return cmp.Global != nil && *st.Global == *cmp.Global + case st.Constant != nil: + return cmp.Constant != nil && st.Constant.Cmp(cmp.Constant) == 0 + default: + return false + } +} + +// Value returns the value of the stake threshold. +func (st *StakeThreshold) Value(tm map[ThresholdKind]quantity.Quantity) (*quantity.Quantity, error) { + switch { + case st.Global != nil: + // Reference to a global threshold. + q := tm[*st.Global] + return &q, nil + case st.Constant != nil: + // Direct constant threshold. + return st.Constant, nil + default: + return nil, fmt.Errorf("staking: invalid claim threshold: %+v", st) + } +} + +// GlobalStakeTreshold creates a new global StakeThreshold. +func GlobalStakeThreshold(kind ThresholdKind) StakeThreshold { + return StakeThreshold{Global: &kind} +} + +// GlobalStakeTresholds creates a new list of global StakeThresholds. +func GlobalStakeThresholds(kinds ...ThresholdKind) (sts []StakeThreshold) { + for _, k := range kinds { + sts = append(sts, GlobalStakeThreshold(k)) + } + return +} + // StakeAccumulator is a per-escrow-account stake accumulator. type StakeAccumulator struct { // Claims are the stake claims that must be satisfied at any given point. Adding a new claim is // only possible if all of the existing claims plus the new claim is satisfied. - Claims map[StakeClaim][]ThresholdKind `json:"claims,omitempty"` + Claims map[StakeClaim][]StakeThreshold `json:"claims,omitempty"` } // AddClaimUnchecked adds a new claim without checking its validity. -func (sa *StakeAccumulator) AddClaimUnchecked(claim StakeClaim, thresholds []ThresholdKind) { +func (sa *StakeAccumulator) AddClaimUnchecked(claim StakeClaim, thresholds []StakeThreshold) { if sa.Claims == nil { - sa.Claims = make(map[StakeClaim][]ThresholdKind) + sa.Claims = make(map[StakeClaim][]StakeThreshold) } sa.Claims[claim] = thresholds @@ -441,9 +540,13 @@ func (sa *StakeAccumulator) TotalClaims(thresholds map[ThresholdKind]quantity.Qu continue } - for _, kind := range claim { - q := thresholds[kind] - if err := total.Add(&q); err != nil { + for _, t := range claim { + q, err := t.Value(thresholds) + if err != nil { + return nil, err + } + + if err = total.Add(q); err != nil { return nil, fmt.Errorf("staking: failed to accumulate threshold: %w", err) } } @@ -482,7 +585,7 @@ func (e *EscrowAccount) CheckStakeClaims(tm map[ThresholdKind]quantity.Quantity) // // In case there is insufficient stake to cover the claim or an error occurrs, no modifications are // made to the stake accumulator. -func (e *EscrowAccount) AddStakeClaim(tm map[ThresholdKind]quantity.Quantity, claim StakeClaim, thresholds []ThresholdKind) error { +func (e *EscrowAccount) AddStakeClaim(tm map[ThresholdKind]quantity.Quantity, claim StakeClaim, thresholds []StakeThreshold) error { // Compute total amount of claims excluding the claim that we are just adding. This is needed // in case the claim is being updated to avoid counting it twice. totalClaims, err := e.StakeAccumulator.TotalClaims(tm, &claim) @@ -490,9 +593,13 @@ func (e *EscrowAccount) AddStakeClaim(tm map[ThresholdKind]quantity.Quantity, cl return err } - for _, kind := range thresholds { - q := tm[kind] - if err := totalClaims.Add(&q); err != nil { + for _, t := range thresholds { + q, err := t.Value(tm) + if err != nil { + return err + } + + if err = totalClaims.Add(q); err != nil { return fmt.Errorf("staking: failed to accumulate threshold: %w", err) } } diff --git a/go/staking/api/api_test.go b/go/staking/api/api_test.go index 19d568dfc2f..e36c6879453 100644 --- a/go/staking/api/api_test.go +++ b/go/staking/api/api_test.go @@ -43,17 +43,81 @@ func TestConsensusParameters(t *testing.T) { require.Error(degenerateFeeSplit.SanityCheck(), "consensus parameters with degenerate fee split should be invalid") } +func TestThresholdKind(t *testing.T) { + require := require.New(t) + + for k := ThresholdKind(0); k <= KindMax; k++ { + enc, err := k.MarshalText() + require.NoError(err, "MarshalText") + + var d ThresholdKind + err = d.UnmarshalText(enc) + require.NoError(err, "UnmarshalText") + require.Equal(k, d, "threshold kind should round-trip") + } +} + +func TestStakeThreshold(t *testing.T) { + require := require.New(t) + + // Empty stake threshold is invalid. + st := StakeThreshold{} + _, err := st.Value(nil) + require.Error(err, "empty stake threshold is invalid") + + // Global threshold reference is resolved correctly. + tm := map[ThresholdKind]quantity.Quantity{ + KindEntity: *quantity.NewFromUint64(1_000), + } + kind := KindEntity + st = StakeThreshold{Global: &kind} + v, err := st.Value(tm) + require.NoError(err, "global threshold reference should be resolved correctly") + q := tm[kind] + require.True(q.Cmp(v) == 0, "global threshold reference should be resolved correctly") + + // Constant threshold is resolved correctly. + c := *quantity.NewFromUint64(5_000) + st = StakeThreshold{Constant: &c} + v, err = st.Value(tm) + require.NoError(err, "constant threshold should be resolved correctly") + require.True(c.Cmp(v) == 0, "constant threshold should be resolved correctly") + + // Equality checks. + kind2 := KindEntity + kind3 := KindNodeCompute + c2 := *quantity.NewFromUint64(1_000) + + for _, t := range []struct { + a StakeThreshold + b StakeThreshold + equal bool + }{ + {StakeThreshold{Global: &kind}, StakeThreshold{Global: &kind}, true}, + {StakeThreshold{Global: &kind}, StakeThreshold{Global: &kind2}, true}, + {StakeThreshold{Global: &kind}, StakeThreshold{Global: &kind3}, false}, + {StakeThreshold{Global: &kind}, StakeThreshold{Constant: &c2}, false}, + {StakeThreshold{Constant: &c2}, StakeThreshold{Constant: &c2}, true}, + {StakeThreshold{Constant: &c}, StakeThreshold{Constant: &c2}, false}, + {StakeThreshold{}, StakeThreshold{Constant: &c2}, false}, + {StakeThreshold{}, StakeThreshold{}, false}, + } { + require.True(t.a.Equal(&t.b) == t.equal, "stake threshold equality should work (a == b)") + require.True(t.b.Equal(&t.a) == t.equal, "stake threshold equality should work (b == a)") + } +} + func TestStakeAccumulator(t *testing.T) { require := require.New(t) thresholds := map[ThresholdKind]quantity.Quantity{ - KindEntity: qtyFromInt(1_000), - KindNodeValidator: qtyFromInt(10_000), - KindNodeCompute: qtyFromInt(5_000), - KindNodeStorage: qtyFromInt(2_000), - KindNodeKeyManager: qtyFromInt(50_000), - KindRuntimeCompute: qtyFromInt(100_000), - KindRuntimeKeyManager: qtyFromInt(1_000_000), + KindEntity: *quantity.NewFromUint64(1_000), + KindNodeValidator: *quantity.NewFromUint64(10_000), + KindNodeCompute: *quantity.NewFromUint64(5_000), + KindNodeStorage: *quantity.NewFromUint64(2_000), + KindNodeKeyManager: *quantity.NewFromUint64(50_000), + KindRuntimeCompute: *quantity.NewFromUint64(100_000), + KindRuntimeKeyManager: *quantity.NewFromUint64(1_000_000), } // Empty escrow account tests. @@ -62,78 +126,86 @@ func TestStakeAccumulator(t *testing.T) { require.NoError(err, "empty escrow account should check out") err = acct.RemoveStakeClaim(StakeClaim("dummy claim")) require.Error(err, "removing a non-existing claim should return an error") - err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), []ThresholdKind{KindEntity, KindNodeValidator}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), GlobalStakeThresholds(KindEntity, KindNodeValidator)) require.Error(err, "adding a stake claim with insufficient stake should fail") require.Equal(err, ErrInsufficientStake) require.EqualValues(EscrowAccount{}, acct, "account should be unchanged after failure") // Add some stake into the account. - acct.Active.Balance = qtyFromInt(3_000) + acct.Active.Balance = *quantity.NewFromUint64(3_000) err = acct.CheckStakeClaims(thresholds) require.NoError(err, "escrow account with no claims should check out") - err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), []ThresholdKind{KindEntity, KindNodeCompute}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), GlobalStakeThresholds(KindEntity, KindNodeCompute)) require.Error(err, "adding a stake claim with insufficient stake should fail") require.Equal(err, ErrInsufficientStake) - err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), []ThresholdKind{KindEntity}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), GlobalStakeThresholds(KindEntity)) require.NoError(err, "adding a stake claim with sufficient stake should work") err = acct.CheckStakeClaims(thresholds) require.NoError(err, "escrow account should check out") // Update an existing claim. - err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), []ThresholdKind{KindEntity, KindNodeCompute}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), GlobalStakeThresholds(KindEntity, KindNodeCompute)) require.Error(err, "updating a stake claim with insufficient stake should fail") require.Equal(err, ErrInsufficientStake) - err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), []ThresholdKind{KindEntity, KindNodeStorage}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), GlobalStakeThresholds(KindEntity, KindNodeStorage)) require.NoError(err, "updating a stake claim with sufficient stake should work") - err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), []ThresholdKind{KindEntity, KindNodeStorage}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim1"), GlobalStakeThresholds(KindEntity, KindNodeStorage)) require.NoError(err, "updating a stake claim with sufficient stake should work") err = acct.CheckStakeClaims(thresholds) require.NoError(err, "escrow account should check out") // Add another claim. - err = acct.AddStakeClaim(thresholds, StakeClaim("claim2"), []ThresholdKind{KindNodeStorage}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim2"), GlobalStakeThresholds(KindNodeStorage)) require.Error(err, "updating a stake claim with insufficient stake should fail") require.Equal(err, ErrInsufficientStake) - acct.Active.Balance = qtyFromInt(13_000) + acct.Active.Balance = *quantity.NewFromUint64(13_000) - err = acct.AddStakeClaim(thresholds, StakeClaim("claim2"), []ThresholdKind{KindNodeStorage}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim2"), GlobalStakeThresholds(KindNodeStorage)) require.NoError(err, "adding a stake claim with sufficient stake should work") err = acct.CheckStakeClaims(thresholds) require.NoError(err, "escrow account should check out") require.Len(acct.StakeAccumulator.Claims, 2, "stake accumulator should contain two claims") - err = acct.AddStakeClaim(thresholds, StakeClaim("claim3"), []ThresholdKind{KindNodeValidator}) + err = acct.AddStakeClaim(thresholds, StakeClaim("claim3"), GlobalStakeThresholds(KindNodeValidator)) require.Error(err, "adding a stake claim with insufficient stake should fail") require.Equal(err, ErrInsufficientStake) + // Add constant claim. + q1 := *quantity.NewFromUint64(10) + err = acct.AddStakeClaim(thresholds, StakeClaim("claimC1"), []StakeThreshold{{Constant: &q1}}) + require.NoError(err, "adding a constant stake claim with sufficient stake should work") + err = acct.CheckStakeClaims(thresholds) + require.NoError(err, "escrow account should check out") + + q2 := *quantity.NewFromUint64(10_000) + err = acct.AddStakeClaim(thresholds, StakeClaim("claimC2"), []StakeThreshold{{Constant: &q2}}) + require.Error(err, "adding a constant stake claim with insufficient stake should fail") + require.Equal(err, ErrInsufficientStake) + // Remove an existing claim. err = acct.RemoveStakeClaim(StakeClaim("claim2")) require.NoError(err, "removing an existing claim should work") + require.Len(acct.StakeAccumulator.Claims, 2, "stake accumulator should contain two claims") + + err = acct.RemoveStakeClaim(StakeClaim("claimC1")) + require.NoError(err, "removing an existing claim should work") require.Len(acct.StakeAccumulator.Claims, 1, "stake accumulator should contain one claim") - err = acct.AddStakeClaim(thresholds, StakeClaim("claim3"), []ThresholdKind{KindNodeValidator}) - require.NoError(err, "adding a stake claim sufficient stake should work") + err = acct.AddStakeClaim(thresholds, StakeClaim("claim3"), GlobalStakeThresholds(KindNodeValidator)) + require.NoError(err, "adding a stake claim with sufficient stake should work") require.Len(acct.StakeAccumulator.Claims, 2, "stake accumulator should contain two claims") err = acct.CheckStakeClaims(thresholds) require.NoError(err, "escrow account should check out") // Reduce stake. - acct.Active.Balance = qtyFromInt(5_000) + acct.Active.Balance = *quantity.NewFromUint64(5_000) err = acct.CheckStakeClaims(thresholds) require.Error(err, "escrow account should no longer check out") require.Equal(err, ErrInsufficientStake) } - -func qtyFromInt(n int) quantity.Quantity { - q := quantity.NewQuantity() - if err := q.FromInt64(int64(n)); err != nil { - panic(err) - } - return *q -} diff --git a/go/upgrade/migrations/dummy.go b/go/upgrade/migrations/dummy.go index f47f0ccec9b..903ecc010d2 100644 --- a/go/upgrade/migrations/dummy.go +++ b/go/upgrade/migrations/dummy.go @@ -62,10 +62,10 @@ func (th *dummyMigrationHandler) ConsensusUpgrade(ctx *Context, privateCtx inter err = stakeState.SetAccount(abciCtx, testEntityAddr, &staking.Account{ Escrow: staking.EscrowAccount{ StakeAccumulator: staking.StakeAccumulator{ - Claims: map[staking.StakeClaim][]staking.ThresholdKind{ - registry.StakeClaimRegisterEntity: []staking.ThresholdKind{ + Claims: map[staking.StakeClaim][]staking.StakeThreshold{ + registry.StakeClaimRegisterEntity: staking.GlobalStakeThresholds( staking.KindEntity, - }, + ), }, }, }, diff --git a/tests/fixture-data/consim/genesis.json b/tests/fixture-data/consim/genesis.json index 1fafa423532..06fe3097290 100644 --- a/tests/fixture-data/consim/genesis.json +++ b/tests/fixture-data/consim/genesis.json @@ -43,13 +43,13 @@ "staking": { "params": { "thresholds": { - "0": "0", - "1": "0", - "2": "0", - "3": "0", - "4": "0", - "5": "0", - "6": "0" + "entity": "0", + "node-validator": "0", + "node-compute": "0", + "node-storage": "0", + "node-keymanager": "0", + "runtime-compute": "0", + "runtime-keymanager": "0" }, "debonding_interval": 2, "commission_schedule_rules": { diff --git a/tests/fixture-data/debond/staking-genesis.json b/tests/fixture-data/debond/staking-genesis.json index 9d3d149681f..cd187f9d582 100644 --- a/tests/fixture-data/debond/staking-genesis.json +++ b/tests/fixture-data/debond/staking-genesis.json @@ -7,13 +7,13 @@ "max_bound_steps": 12 }, "thresholds": { - "0": "0", - "1": "0", - "2": "0", - "3": "0", - "4": "0", - "5": "0", - "6": "0" + "entity": "0", + "node-validator": "0", + "node-compute": "0", + "node-storage": "0", + "node-keymanager": "0", + "runtime-compute": "0", + "runtime-keymanager": "0" } }, "total_supply": "1000", diff --git a/tests/fixture-data/net-runner/default.json b/tests/fixture-data/net-runner/default.json index 5c36ee463fc..040b47b1502 100644 --- a/tests/fixture-data/net-runner/default.json +++ b/tests/fixture-data/net-runner/default.json @@ -71,6 +71,7 @@ "admission_policy": { "any_node": {} }, + "staking": {}, "pruner": { "strategy": "", "interval": 0, @@ -119,6 +120,7 @@ "admission_policy": { "any_node": {} }, + "staking": {}, "pruner": { "strategy": "", "interval": 0, diff --git a/tests/fixture-data/runtime-dynamic/staking-genesis.json b/tests/fixture-data/runtime-dynamic/staking-genesis.json index b81b9fbd10a..d903b906aad 100644 --- a/tests/fixture-data/runtime-dynamic/staking-genesis.json +++ b/tests/fixture-data/runtime-dynamic/staking-genesis.json @@ -1,13 +1,13 @@ { "params": { "thresholds": { - "0": "0", - "1": "0", - "2": "0", - "3": "0", - "4": "0", - "5": "1000", - "6": "1000" + "entity": "0", + "node-validator": "0", + "node-compute": "0", + "node-storage": "0", + "node-keymanager": "0", + "runtime-compute": "1000", + "runtime-keymanager": "1000" } } } diff --git a/tests/fixture-data/stake-cli/staking-genesis.json b/tests/fixture-data/stake-cli/staking-genesis.json index fd1403bff88..d4d9632b34b 100644 --- a/tests/fixture-data/stake-cli/staking-genesis.json +++ b/tests/fixture-data/stake-cli/staking-genesis.json @@ -7,13 +7,13 @@ "max_bound_steps": 12 }, "thresholds": { - "0": "0", - "1": "0", - "2": "0", - "3": "0", - "4": "0", - "5": "0", - "6": "0" + "entity": "0", + "node-validator": "0", + "node-compute": "0", + "node-storage": "0", + "node-keymanager": "0", + "runtime-compute": "0", + "runtime-keymanager": "0" } } }