mirror of
https://github.com/nspcc-dev/neo-go.git
synced 2024-11-29 23:33:37 +00:00
Merge pull request #1827 from nspcc-dev/native/update_history
config: add NativeUpdateHistory
This commit is contained in:
commit
a18fbc7bb1
27 changed files with 270 additions and 11 deletions
|
@ -21,6 +21,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: false
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -17,6 +17,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -17,6 +17,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -11,6 +11,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -17,6 +17,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -17,6 +17,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -17,6 +17,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -21,6 +21,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: false
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -9,6 +9,17 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: false
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -18,6 +18,18 @@ ProtocolConfiguration:
|
|||
VerifyBlocks: true
|
||||
VerifyTransactions: true
|
||||
P2PSigExtensions: true
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
StdLib: [0]
|
||||
CryptoLib: [0]
|
||||
LedgerContract: [0]
|
||||
NeoToken: [0]
|
||||
GasToken: [0]
|
||||
PolicyContract: [0]
|
||||
RoleManagement: [0]
|
||||
OracleContract: [0]
|
||||
NameService: [0]
|
||||
Notary: [0]
|
||||
|
||||
ApplicationConfiguration:
|
||||
# LogPath could be set up in case you need stdout logs to some proper file.
|
||||
|
|
|
@ -29,7 +29,7 @@ import (
|
|||
)
|
||||
|
||||
func TestContractHashes(t *testing.T) {
|
||||
cs := native.NewContracts(true)
|
||||
cs := native.NewContracts(true, map[string][]uint32{})
|
||||
require.Equal(t, []byte(neo.Hash), cs.NEO.Hash.BytesBE())
|
||||
require.Equal(t, []byte(gas.Hash), cs.GAS.Hash.BytesBE())
|
||||
require.Equal(t, []byte(oracle.Hash), cs.Oracle.Hash.BytesBE())
|
||||
|
@ -93,7 +93,7 @@ type nativeTestCase struct {
|
|||
|
||||
// Here we test that corresponding method does exist, is invoked and correct value is returned.
|
||||
func TestNativeHelpersCompile(t *testing.T) {
|
||||
cs := native.NewContracts(true)
|
||||
cs := native.NewContracts(true, map[string][]uint32{})
|
||||
u160 := `interop.Hash160("aaaaaaaaaaaaaaaaaaaa")`
|
||||
u256 := `interop.Hash256("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")`
|
||||
pub := `interop.PublicKey("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")`
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
|
||||
"github.com/nspcc-dev/neo-go/pkg/config/netmode"
|
||||
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
|
@ -56,5 +57,11 @@ func LoadFile(configPath string) (Config, error) {
|
|||
return Config{}, fmt.Errorf("failed to unmarshal config YAML: %w", err)
|
||||
}
|
||||
|
||||
for name := range config.ProtocolConfiguration.NativeUpdateHistories {
|
||||
if !nativenames.IsValid(name) {
|
||||
return Config{}, fmt.Errorf("NativeActivations configuration section contains unexpected native contract name: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
|
14
pkg/config/config_test.go
Normal file
14
pkg/config/config_test.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testConfigPath = "./testdata/protocol.test.yml"
|
||||
|
||||
func TestUnexpectedNativeUpdateHistoryContract(t *testing.T) {
|
||||
_, err := LoadFile(testConfigPath)
|
||||
require.Error(t, err)
|
||||
}
|
|
@ -22,6 +22,8 @@ type (
|
|||
MaxTraceableBlocks uint32 `yaml:"MaxTraceableBlocks"`
|
||||
// MaxTransactionsPerBlock is the maximum amount of transactions per block.
|
||||
MaxTransactionsPerBlock uint16 `yaml:"MaxTransactionsPerBlock"`
|
||||
// NativeUpdateHistories is the list of histories of native contracts updates.
|
||||
NativeUpdateHistories map[string][]uint32 `yaml:"NativeActivations"`
|
||||
// P2PSigExtensions enables additional signature-related logic.
|
||||
P2PSigExtensions bool `yaml:"P2PSigExtensions"`
|
||||
// ReservedAttributes allows to have reserved attributes range for experimental or private purposes.
|
||||
|
|
4
pkg/config/testdata/protocol.test.yml
vendored
Normal file
4
pkg/config/testdata/protocol.test.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
ProtocolConfiguration:
|
||||
NativeActivations:
|
||||
ContractManagement: [0]
|
||||
UnexpectedContractName: [0]
|
|
@ -180,6 +180,10 @@ func NewBlockchain(s storage.Store, cfg config.ProtocolConfiguration, log *zap.L
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.NativeUpdateHistories) == 0 {
|
||||
cfg.NativeUpdateHistories = map[string][]uint32{}
|
||||
log.Info("NativeActivations are not set, using default values")
|
||||
}
|
||||
bc := &Blockchain{
|
||||
config: cfg,
|
||||
dao: dao.NewSimple(s, cfg.Magic, cfg.StateRootInHeader),
|
||||
|
@ -192,7 +196,7 @@ func NewBlockchain(s storage.Store, cfg config.ProtocolConfiguration, log *zap.L
|
|||
subCh: make(chan interface{}),
|
||||
unsubCh: make(chan interface{}),
|
||||
|
||||
contracts: *native.NewContracts(cfg.P2PSigExtensions),
|
||||
contracts: *native.NewContracts(cfg.P2PSigExtensions, cfg.NativeUpdateHistories),
|
||||
}
|
||||
|
||||
bc.stateRoot = stateroot.NewModule(bc, bc.log, bc.dao.Store)
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
@ -1614,3 +1615,40 @@ func TestMPTDeleteNoKey(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Equal(t, vm.HaltState, aer.VMState)
|
||||
}
|
||||
|
||||
// Test that UpdateHistory is added to ProtocolConfiguration for all native contracts
|
||||
// for all default configurations. If UpdateHistory is not added to config, then
|
||||
// native contract is disabled. It's easy to forget about config while adding new
|
||||
// native contract.
|
||||
func TestConfigNativeUpdateHistory(t *testing.T) {
|
||||
const prefixPath = "../../config"
|
||||
check := func(t *testing.T, cfgFileSuffix interface{}) {
|
||||
cfgPath := path.Join(prefixPath, fmt.Sprintf("protocol.%s.yml", cfgFileSuffix))
|
||||
cfg, err := config.LoadFile(cfgPath)
|
||||
require.NoError(t, err, fmt.Errorf("failed to load %s", cfgPath))
|
||||
natives := native.NewContracts(cfg.ProtocolConfiguration.P2PSigExtensions, map[string][]uint32{})
|
||||
assert.Equal(t, len(natives.Contracts),
|
||||
len(cfg.ProtocolConfiguration.NativeUpdateHistories),
|
||||
fmt.Errorf("protocol configuration file %s: extra or missing NativeUpdateHistory in NativeActivations section", cfgPath))
|
||||
for _, c := range natives.Contracts {
|
||||
assert.NotNil(t, cfg.ProtocolConfiguration.NativeUpdateHistories[c.Metadata().Name],
|
||||
fmt.Errorf("protocol configuration file %s: configuration for %s native contract is missing in NativeActivations section; "+
|
||||
"edit the test if the contract should be disabled", cfgPath, c.Metadata().Name))
|
||||
}
|
||||
}
|
||||
testCases := []interface{}{
|
||||
netmode.MainNet,
|
||||
netmode.PrivNet,
|
||||
netmode.TestNet,
|
||||
netmode.UnitTestNet,
|
||||
"privnet.docker.one",
|
||||
"privnet.docker.two",
|
||||
"privnet.docker.three",
|
||||
"privnet.docker.four",
|
||||
"privnet.docker.single",
|
||||
"unit_testnet.single",
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
check(t, tc)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -209,6 +209,12 @@ func (c *ContractMD) AddEvent(name string, ps ...manifest.Parameter) {
|
|||
})
|
||||
}
|
||||
|
||||
// IsActive returns true iff the contract was deployed by the specified height.
|
||||
func (c *ContractMD) IsActive(height uint32) bool {
|
||||
history := c.UpdateHistory
|
||||
return len(history) != 0 && history[0] <= height
|
||||
}
|
||||
|
||||
// Sort sorts interop functions by id.
|
||||
func Sort(fs []Function) {
|
||||
sort.Slice(fs, func(i, j int) bool { return fs[i].ID < fs[j].ID })
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
// "C" and "O" can easily be typed by accident.
|
||||
func TestNamesASCII(t *testing.T) {
|
||||
cs := NewContracts(true)
|
||||
cs := NewContracts(true, map[string][]uint32{})
|
||||
for _, c := range cs.Contracts {
|
||||
require.True(t, isASCII(c.Metadata().Name))
|
||||
for _, m := range c.Metadata().Methods {
|
||||
|
|
|
@ -56,7 +56,7 @@ func (cs *Contracts) ByName(name string) interop.Contract {
|
|||
|
||||
// NewContracts returns new set of native contracts with new GAS, NEO, Policy, Oracle,
|
||||
// Designate and (optional) Notary contracts.
|
||||
func NewContracts(p2pSigExtensionsEnabled bool) *Contracts {
|
||||
func NewContracts(p2pSigExtensionsEnabled bool, nativeUpdateHistories map[string][]uint32) *Contracts {
|
||||
cs := new(Contracts)
|
||||
|
||||
mgmt := newManagement()
|
||||
|
@ -117,6 +117,13 @@ func NewContracts(p2pSigExtensionsEnabled bool) *Contracts {
|
|||
cs.Contracts = append(cs.Contracts, notary)
|
||||
}
|
||||
|
||||
setDefaultHistory := len(nativeUpdateHistories) == 0
|
||||
for _, c := range cs.Contracts {
|
||||
if setDefaultHistory {
|
||||
nativeUpdateHistories[c.Metadata().Name] = []uint32{0}
|
||||
}
|
||||
c.Metadata().NativeContract.UpdateHistory = nativeUpdateHistories[c.Metadata().Name]
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,13 @@ func Call(ic *interop.Context) error {
|
|||
if c == nil {
|
||||
return fmt.Errorf("native contract %d not found", version)
|
||||
}
|
||||
history := c.Metadata().UpdateHistory
|
||||
if len(history) == 0 {
|
||||
return fmt.Errorf("native contract %s is disabled", c.Metadata().Name)
|
||||
}
|
||||
if history[0] > ic.Chain.BlockHeight() {
|
||||
return fmt.Errorf("native contract %s is active after height = %d", c.Metadata().Name, history[0])
|
||||
}
|
||||
m, ok := c.Metadata().GetMethodByOffset(ic.VM.Context().IP())
|
||||
if !ok {
|
||||
return fmt.Errorf("method not found")
|
||||
|
@ -57,6 +64,9 @@ func OnPersist(ic *interop.Context) error {
|
|||
return errors.New("onPersist must be trigered by system")
|
||||
}
|
||||
for _, c := range ic.Natives {
|
||||
if !c.Metadata().IsActive(ic.Block.Index) {
|
||||
continue
|
||||
}
|
||||
err := c.OnPersist(ic)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -71,6 +81,9 @@ func PostPersist(ic *interop.Context) error {
|
|||
return errors.New("postPersist must be trigered by system")
|
||||
}
|
||||
for _, c := range ic.Natives {
|
||||
if !c.Metadata().IsActive(ic.Block.Index) {
|
||||
continue
|
||||
}
|
||||
err := c.PostPersist(ic)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -446,12 +446,12 @@ func (m *Management) Metadata() *interop.ContractMD {
|
|||
|
||||
// OnPersist implements Contract interface.
|
||||
func (m *Management) OnPersist(ic *interop.Context) error {
|
||||
if ic.Block.Index != 0 { // We're only deploying at 0 at the moment.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, native := range ic.Natives {
|
||||
md := native.Metadata()
|
||||
history := md.UpdateHistory
|
||||
if len(history) == 0 || history[0] != ic.Block.Index {
|
||||
continue
|
||||
}
|
||||
|
||||
cs := &state.Contract{
|
||||
ContractBase: md.ContractBase,
|
||||
|
|
|
@ -14,3 +14,18 @@ const (
|
|||
CryptoLib = "CryptoLib"
|
||||
StdLib = "StdLib"
|
||||
)
|
||||
|
||||
// IsValid checks that name is a valid native contract's name.
|
||||
func IsValid(name string) bool {
|
||||
return name == Management ||
|
||||
name == Ledger ||
|
||||
name == Neo ||
|
||||
name == Gas ||
|
||||
name == Policy ||
|
||||
name == Oracle ||
|
||||
name == Designation ||
|
||||
name == Notary ||
|
||||
name == NameService ||
|
||||
name == CryptoLib ||
|
||||
name == StdLib
|
||||
}
|
||||
|
|
19
pkg/core/native/nativenames_test.go
Normal file
19
pkg/core/native/nativenames_test.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package native
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNativenamesIsValid(t *testing.T) {
|
||||
// test that all native names has been added to IsValid
|
||||
contracts := NewContracts(true, map[string][]uint32{})
|
||||
for _, c := range contracts.Contracts {
|
||||
require.True(t, nativenames.IsValid(c.Metadata().Name), fmt.Errorf("add %s to nativenames.IsValid(...)", c))
|
||||
}
|
||||
|
||||
require.False(t, nativenames.IsValid("unkonwn"))
|
||||
}
|
|
@ -54,6 +54,7 @@ var _ interop.Contract = (*testNative)(nil)
|
|||
// registerNative registers native contract in the blockchain.
|
||||
func (bc *Blockchain) registerNative(c interop.Contract) {
|
||||
bc.contracts.Contracts = append(bc.contracts.Contracts, c)
|
||||
bc.config.NativeUpdateHistories[c.Metadata().Name] = c.Metadata().UpdateHistory
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -62,8 +63,10 @@ const (
|
|||
)
|
||||
|
||||
func newTestNative() *testNative {
|
||||
cMD := interop.NewContractMD("Test.Native.Sum", 0)
|
||||
cMD.UpdateHistory = []uint32{0}
|
||||
tn := &testNative{
|
||||
meta: *interop.NewContractMD("Test.Native.Sum", 0),
|
||||
meta: *cMD,
|
||||
blocks: make(chan uint32, 1),
|
||||
}
|
||||
defer tn.meta.UpdateHash()
|
||||
|
@ -246,6 +249,21 @@ func TestNativeContract_InvokeInternal(t *testing.T) {
|
|||
require.Error(t, v.Run())
|
||||
})
|
||||
|
||||
t.Run("fail, bad NativeUpdateHistory height", func(t *testing.T) {
|
||||
tn.Metadata().UpdateHistory = []uint32{chain.blockHeight + 1}
|
||||
v := ic.SpawnVM()
|
||||
v.LoadScriptWithHash(tn.Metadata().NEF.Script, tn.Metadata().Hash, callflag.All)
|
||||
v.Estack().PushVal(14)
|
||||
v.Estack().PushVal(28)
|
||||
v.Jump(v.Context(), sumOffset)
|
||||
|
||||
// it's prohibited to call natives before NativeUpdateHistory[0] height
|
||||
require.Error(t, v.Run())
|
||||
|
||||
// set the value back to 0
|
||||
tn.Metadata().UpdateHistory = []uint32{0}
|
||||
})
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
v := ic.SpawnVM()
|
||||
v.LoadScriptWithHash(tn.Metadata().NEF.Script, tn.Metadata().Hash, callflag.All)
|
||||
|
|
|
@ -32,7 +32,7 @@ type ContractBase struct {
|
|||
// NativeContract holds information about native contract.
|
||||
type NativeContract struct {
|
||||
ContractBase
|
||||
ActiveBlockIndex uint32 `json:"activeblockindex"`
|
||||
UpdateHistory []uint32 `json:"updatehistory"`
|
||||
}
|
||||
|
||||
// DecodeBinary implements Serializable interface.
|
||||
|
|
|
@ -566,6 +566,7 @@ var rpcTestCases = map[string][]rpcTestCase{
|
|||
cs := e.chain.GetContractState((*lst)[i].Hash)
|
||||
require.NotNil(t, cs)
|
||||
require.True(t, cs.ID <= 0)
|
||||
require.Equal(t, []uint32{0}, (*lst)[i].UpdateHistory)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue