forked from TrueCloudLab/neoneo-go
Merge pull request #2414 from nspcc-dev/eliminate-getstorageitems
Eliminate GetStorageItems
This commit is contained in:
commit
4e375fd8f4
11 changed files with 70 additions and 145 deletions
|
@ -325,11 +325,6 @@ func (chain *FakeChain) GetTestVM(t trigger.Type, tx *transaction.Transaction, b
|
||||||
panic("TODO")
|
panic("TODO")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStorageItems implements Blockchainer interface.
|
|
||||||
func (chain *FakeChain) GetStorageItems(id int32) ([]state.StorageItemWithKey, error) {
|
|
||||||
panic("TODO")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrentHeaderHash implements Blockchainer interface.
|
// CurrentHeaderHash implements Blockchainer interface.
|
||||||
func (chain *FakeChain) CurrentHeaderHash() util.Uint256 {
|
func (chain *FakeChain) CurrentHeaderHash() util.Uint256 {
|
||||||
return util.Uint256{}
|
return util.Uint256{}
|
||||||
|
|
|
@ -1589,11 +1589,6 @@ func (bc *Blockchain) GetStorageItem(id int32, key []byte) state.StorageItem {
|
||||||
return bc.dao.GetStorageItem(id, key)
|
return bc.dao.GetStorageItem(id, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStorageItems returns all storage items for a given contract id.
|
|
||||||
func (bc *Blockchain) GetStorageItems(id int32) ([]state.StorageItemWithKey, error) {
|
|
||||||
return bc.dao.GetStorageItems(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBlock returns a Block by the given hash.
|
// GetBlock returns a Block by the given hash.
|
||||||
func (bc *Blockchain) GetBlock(hash util.Uint256) (*block.Block, error) {
|
func (bc *Blockchain) GetBlock(hash util.Uint256) (*block.Block, error) {
|
||||||
topBlock := bc.topBlock.Load()
|
topBlock := bc.topBlock.Load()
|
||||||
|
|
|
@ -59,7 +59,6 @@ type Blockchainer interface {
|
||||||
GetValidators() ([]*keys.PublicKey, error)
|
GetValidators() ([]*keys.PublicKey, error)
|
||||||
GetStateModule() StateRoot
|
GetStateModule() StateRoot
|
||||||
GetStorageItem(id int32, key []byte) state.StorageItem
|
GetStorageItem(id int32, key []byte) state.StorageItem
|
||||||
GetStorageItems(id int32) ([]state.StorageItemWithKey, error)
|
|
||||||
GetTestVM(t trigger.Type, tx *transaction.Transaction, b *block.Block) *interop.Context
|
GetTestVM(t trigger.Type, tx *transaction.Transaction, b *block.Block) *interop.Context
|
||||||
GetTransaction(util.Uint256) (*transaction.Transaction, uint32, error)
|
GetTransaction(util.Uint256) (*transaction.Transaction, uint32, error)
|
||||||
SetOracle(service services.Oracle)
|
SetOracle(service services.Oracle)
|
||||||
|
|
|
@ -310,29 +310,6 @@ func (dao *Simple) DeleteStorageItem(id int32, key []byte) {
|
||||||
dao.Store.Delete(stKey)
|
dao.Store.Delete(stKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStorageItems returns all storage items for a given id.
|
|
||||||
func (dao *Simple) GetStorageItems(id int32) ([]state.StorageItemWithKey, error) {
|
|
||||||
return dao.GetStorageItemsWithPrefix(id, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStorageItemsWithPrefix returns all storage items with given id for a
|
|
||||||
// given scripthash.
|
|
||||||
func (dao *Simple) GetStorageItemsWithPrefix(id int32, prefix []byte) ([]state.StorageItemWithKey, error) {
|
|
||||||
var siArr []state.StorageItemWithKey
|
|
||||||
|
|
||||||
saveToArr := func(k, v []byte) bool {
|
|
||||||
// Cut prefix and hash.
|
|
||||||
// #1468, but don't need to copy here, because it is done by Store.
|
|
||||||
siArr = append(siArr, state.StorageItemWithKey{
|
|
||||||
Key: k,
|
|
||||||
Item: state.StorageItem(v),
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
dao.Seek(id, storage.SeekRange{Prefix: prefix}, saveToArr)
|
|
||||||
return siArr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek executes f for all storage items matching a given `rng` (matching given prefix and
|
// Seek executes f for all storage items matching a given `rng` (matching given prefix and
|
||||||
// starting from the point specified). If key or value is to be used outside of f, they
|
// starting from the point specified). If key or value is to be used outside of f, they
|
||||||
// may not be copied. Seek continues iterating until false is returned from f.
|
// may not be copied. Seek continues iterating until false is returned from f.
|
||||||
|
|
|
@ -189,8 +189,6 @@ func storageFind(ic *interop.Context) error {
|
||||||
if opts&istorage.FindDeserialize == 0 && (opts&istorage.FindPick0 != 0 || opts&istorage.FindPick1 != 0) {
|
if opts&istorage.FindDeserialize == 0 && (opts&istorage.FindPick0 != 0 || opts&istorage.FindPick1 != 0) {
|
||||||
return fmt.Errorf("%w: PickN is specified without Deserialize", errFindInvalidOptions)
|
return fmt.Errorf("%w: PickN is specified without Deserialize", errFindInvalidOptions)
|
||||||
}
|
}
|
||||||
// Items in seekres should be sorted by key, but GetStorageItemsWithPrefix returns
|
|
||||||
// sorted items, so no need to sort them one more time.
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
seekres := ic.DAO.SeekAsync(ctx, stc.ID, storage.SeekRange{Prefix: prefix})
|
seekres := ic.DAO.SeekAsync(ctx, stc.ID, storage.SeekRange{Prefix: prefix})
|
||||||
item := istorage.NewIterator(seekres, prefix, opts)
|
item := istorage.NewIterator(seekres, prefix, opts)
|
||||||
|
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/native/noderoles"
|
"github.com/nspcc-dev/neo-go/pkg/core/native/noderoles"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/state"
|
"github.com/nspcc-dev/neo-go/pkg/core/state"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/stateroot"
|
"github.com/nspcc-dev/neo-go/pkg/core/stateroot"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/core/storage"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/crypto/hash"
|
"github.com/nspcc-dev/neo-go/pkg/crypto/hash"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
|
"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/smartcontract"
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract"
|
||||||
|
@ -253,35 +254,31 @@ func (s *Designate) GetDesignatedByRole(d *dao.Simple, r noderoles.Role, index u
|
||||||
return val.nodes.Copy(), val.height, nil
|
return val.nodes.Copy(), val.height, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kvs, err := d.GetStorageItemsWithPrefix(s.ID, []byte{byte(r)})
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
var (
|
var (
|
||||||
ns NodeList
|
ns NodeList
|
||||||
bestIndex uint32
|
bestIndex uint32
|
||||||
resSi state.StorageItem
|
resVal []byte
|
||||||
|
start = make([]byte, 4)
|
||||||
)
|
)
|
||||||
// kvs are sorted by key (BE index bytes) in ascending way, so iterate backwards to get the latest designated.
|
|
||||||
for i := len(kvs) - 1; i >= 0; i-- {
|
binary.BigEndian.PutUint32(start, index)
|
||||||
kv := kvs[i]
|
d.Seek(s.ID, storage.SeekRange{
|
||||||
if len(kv.Key) < 4 {
|
Prefix: []byte{byte(r)},
|
||||||
continue
|
Start: start,
|
||||||
}
|
Backwards: true,
|
||||||
siInd := binary.BigEndian.Uint32(kv.Key)
|
}, func(k, v []byte) bool {
|
||||||
if siInd <= index {
|
bestIndex = binary.BigEndian.Uint32(k) // If len(k) < 4 the DB is broken and it deserves a panic.
|
||||||
bestIndex = siInd
|
resVal = v
|
||||||
resSi = kv.Item
|
// Take just the latest item, it's the one we need.
|
||||||
break
|
return false
|
||||||
}
|
})
|
||||||
}
|
if resVal != nil {
|
||||||
if resSi != nil {
|
err := stackitem.DeserializeConvertible(resVal, &ns)
|
||||||
err = stackitem.DeserializeConvertible(resSi, &ns)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return keys.PublicKeys(ns), bestIndex, err
|
return keys.PublicKeys(ns), bestIndex, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Designate) designateAsRole(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
func (s *Designate) designateAsRole(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
||||||
|
|
|
@ -388,13 +388,11 @@ func (m *Management) Destroy(d *dao.Simple, hash util.Uint160) error {
|
||||||
key := MakeContractKey(hash)
|
key := MakeContractKey(hash)
|
||||||
d.DeleteStorageItem(m.ID, key)
|
d.DeleteStorageItem(m.ID, key)
|
||||||
d.DeleteContractID(contract.ID)
|
d.DeleteContractID(contract.ID)
|
||||||
siArr, err := d.GetStorageItems(contract.ID)
|
|
||||||
if err != nil {
|
d.Seek(contract.ID, storage.SeekRange{}, func(k, _ []byte) bool {
|
||||||
return err
|
d.DeleteStorageItem(contract.ID, k)
|
||||||
}
|
return true
|
||||||
for _, kv := range siArr {
|
})
|
||||||
d.DeleteStorageItem(contract.ID, []byte(kv.Key))
|
|
||||||
}
|
|
||||||
m.markUpdated(hash)
|
m.markUpdated(hash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -255,11 +255,7 @@ func (n *NEO) InitializeCache(bc interop.Ledger, d *dao.Simple) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
gr, err := n.getSortedGASRecordFromDAO(d)
|
n.gasPerBlock.Store(n.getSortedGASRecordFromDAO(d))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n.gasPerBlock.Store(gr)
|
|
||||||
n.gasPerBlockChanged.Store(false)
|
n.gasPerBlockChanged.Store(false)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -375,11 +371,7 @@ func (n *NEO) PostPersist(ic *interop.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n.gasPerBlockChanged.Load().(bool) {
|
if n.gasPerBlockChanged.Load().(bool) {
|
||||||
gr, err := n.getSortedGASRecordFromDAO(ic.DAO)
|
n.gasPerBlock.Store(n.getSortedGASRecordFromDAO(ic.DAO))
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
n.gasPerBlock.Store(gr)
|
|
||||||
n.gasPerBlockChanged.Store(false)
|
n.gasPerBlockChanged.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -495,35 +487,23 @@ func (n *NEO) getGASPerBlock(ic *interop.Context, _ []stackitem.Item) stackitem.
|
||||||
return stackitem.NewBigInteger(gas)
|
return stackitem.NewBigInteger(gas)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *NEO) getSortedGASRecordFromDAO(d *dao.Simple) (gasRecord, error) {
|
func (n *NEO) getSortedGASRecordFromDAO(d *dao.Simple) gasRecord {
|
||||||
grArr, err := d.GetStorageItemsWithPrefix(n.ID, []byte{prefixGASPerBlock})
|
var gr = make(gasRecord, 0)
|
||||||
if err != nil {
|
d.Seek(n.ID, storage.SeekRange{Prefix: []byte{prefixGASPerBlock}}, func(k, v []byte) bool {
|
||||||
return gasRecord{}, fmt.Errorf("failed to get gas records from storage: %w", err)
|
gr = append(gr, gasIndexPair{
|
||||||
}
|
Index: binary.BigEndian.Uint32(k),
|
||||||
var gr = make(gasRecord, len(grArr))
|
GASPerBlock: *bigint.FromBytes(v),
|
||||||
for i, kv := range grArr {
|
})
|
||||||
indexBytes, gasValue := kv.Key, kv.Item
|
return true
|
||||||
gr[i] = gasIndexPair{
|
})
|
||||||
Index: binary.BigEndian.Uint32([]byte(indexBytes)),
|
return gr
|
||||||
GASPerBlock: *bigint.FromBytes(gasValue),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// GAS records should be sorted by index, but GetStorageItemsWithPrefix returns
|
|
||||||
// values sorted by BE bytes of index, so we're OK with that.
|
|
||||||
return gr, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGASPerBlock returns gas generated for block with provided index.
|
// GetGASPerBlock returns gas generated for block with provided index.
|
||||||
func (n *NEO) GetGASPerBlock(d *dao.Simple, index uint32) *big.Int {
|
func (n *NEO) GetGASPerBlock(d *dao.Simple, index uint32) *big.Int {
|
||||||
var (
|
var gr gasRecord
|
||||||
gr gasRecord
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if n.gasPerBlockChanged.Load().(bool) {
|
if n.gasPerBlockChanged.Load().(bool) {
|
||||||
gr, err = n.getSortedGASRecordFromDAO(d)
|
gr = n.getSortedGASRecordFromDAO(d)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
gr = n.gasPerBlock.Load().(gasRecord)
|
gr = n.gasPerBlock.Load().(gasRecord)
|
||||||
}
|
}
|
||||||
|
@ -665,17 +645,11 @@ func (n *NEO) CalculateNEOHolderReward(d *dao.Simple, value *big.Int, start, end
|
||||||
} else if value.Sign() < 0 {
|
} else if value.Sign() < 0 {
|
||||||
return nil, errors.New("negative value")
|
return nil, errors.New("negative value")
|
||||||
}
|
}
|
||||||
var (
|
var gr gasRecord
|
||||||
gr gasRecord
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if !n.gasPerBlockChanged.Load().(bool) {
|
if !n.gasPerBlockChanged.Load().(bool) {
|
||||||
gr = n.gasPerBlock.Load().(gasRecord)
|
gr = n.gasPerBlock.Load().(gasRecord)
|
||||||
} else {
|
} else {
|
||||||
gr, err = n.getSortedGASRecordFromDAO(d)
|
gr = n.getSortedGASRecordFromDAO(d)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var sum, tmp big.Int
|
var sum, tmp big.Int
|
||||||
for i := len(gr) - 1; i >= 0; i-- {
|
for i := len(gr) - 1; i >= 0; i-- {
|
||||||
|
@ -847,17 +821,15 @@ func (n *NEO) ModifyAccountVotes(acc *state.NEOBalance, d *dao.Simple, value *bi
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *NEO) getCandidates(d *dao.Simple, sortByKey bool) ([]keyWithVotes, error) {
|
func (n *NEO) getCandidates(d *dao.Simple, sortByKey bool) ([]keyWithVotes, error) {
|
||||||
siArr, err := d.GetStorageItemsWithPrefix(n.ID, []byte{prefixCandidate})
|
arr := make([]keyWithVotes, 0)
|
||||||
if err != nil {
|
d.Seek(n.ID, storage.SeekRange{Prefix: []byte{prefixCandidate}}, func(k, v []byte) bool {
|
||||||
return nil, err
|
c := new(candidate).FromBytes(v)
|
||||||
}
|
|
||||||
arr := make([]keyWithVotes, 0, len(siArr))
|
|
||||||
for _, kv := range siArr {
|
|
||||||
c := new(candidate).FromBytes(kv.Item)
|
|
||||||
if c.Registered {
|
if c.Registered {
|
||||||
arr = append(arr, keyWithVotes{Key: string(kv.Key), Votes: &c.Votes})
|
arr = append(arr, keyWithVotes{Key: string(k), Votes: &c.Votes})
|
||||||
}
|
}
|
||||||
}
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if !sortByKey {
|
if !sortByKey {
|
||||||
// sortByKey assumes to sort by serialized key bytes (that's the way keys
|
// sortByKey assumes to sort by serialized key bytes (that's the way keys
|
||||||
// are stored and retrieved from the storage by default). Otherwise, need
|
// are stored and retrieved from the storage by default). Otherwise, need
|
||||||
|
|
|
@ -471,22 +471,24 @@ func (o *Oracle) getOriginalTxID(d *dao.Simple, tx *transaction.Transaction) uti
|
||||||
|
|
||||||
// getRequests returns all requests which have not been finished yet.
|
// getRequests returns all requests which have not been finished yet.
|
||||||
func (o *Oracle) getRequests(d *dao.Simple) (map[uint64]*state.OracleRequest, error) {
|
func (o *Oracle) getRequests(d *dao.Simple) (map[uint64]*state.OracleRequest, error) {
|
||||||
arr, err := d.GetStorageItemsWithPrefix(o.ID, prefixRequest)
|
var reqs = make(map[uint64]*state.OracleRequest)
|
||||||
if err != nil {
|
var err error
|
||||||
return nil, err
|
d.Seek(o.ID, storage.SeekRange{Prefix: prefixRequest}, func(k, v []byte) bool {
|
||||||
}
|
if len(k) != 8 {
|
||||||
reqs := make(map[uint64]*state.OracleRequest, len(arr))
|
err = errors.New("invalid request ID")
|
||||||
for _, kv := range arr {
|
return false
|
||||||
if len(kv.Key) != 8 {
|
|
||||||
return nil, errors.New("invalid request ID")
|
|
||||||
}
|
}
|
||||||
req := new(state.OracleRequest)
|
req := new(state.OracleRequest)
|
||||||
err = stackitem.DeserializeConvertible(kv.Item, req)
|
err = stackitem.DeserializeConvertible(v, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false
|
||||||
}
|
}
|
||||||
id := binary.BigEndian.Uint64([]byte(kv.Key))
|
id := binary.BigEndian.Uint64(k)
|
||||||
reqs[id] = req
|
reqs[id] = req
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return reqs, nil
|
return reqs, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/interop"
|
"github.com/nspcc-dev/neo-go/pkg/core/interop"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/state"
|
"github.com/nspcc-dev/neo-go/pkg/core/state"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/core/storage"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/transaction"
|
"github.com/nspcc-dev/neo-go/pkg/core/transaction"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/smartcontract"
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag"
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag"
|
||||||
|
@ -156,23 +157,20 @@ func (p *Policy) PostPersist(ic *interop.Context) error {
|
||||||
p.storagePrice = uint32(getIntWithKey(p.ID, ic.DAO, storagePriceKey))
|
p.storagePrice = uint32(getIntWithKey(p.ID, ic.DAO, storagePriceKey))
|
||||||
|
|
||||||
p.blockedAccounts = make([]util.Uint160, 0)
|
p.blockedAccounts = make([]util.Uint160, 0)
|
||||||
siArr, err := ic.DAO.GetStorageItemsWithPrefix(p.ID, []byte{blockedAccountPrefix})
|
var fErr error
|
||||||
if err != nil {
|
ic.DAO.Seek(p.ID, storage.SeekRange{Prefix: []byte{blockedAccountPrefix}}, func(k, _ []byte) bool {
|
||||||
return fmt.Errorf("failed to get blocked accounts from storage: %w", err)
|
hash, err := util.Uint160DecodeBytesBE(k)
|
||||||
}
|
|
||||||
for _, kv := range siArr {
|
|
||||||
hash, err := util.Uint160DecodeBytesBE([]byte(kv.Key))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decode blocked account hash: %w", err)
|
fErr = fmt.Errorf("failed to decode blocked account hash: %w", err)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
p.blockedAccounts = append(p.blockedAccounts, hash)
|
p.blockedAccounts = append(p.blockedAccounts, hash)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if fErr == nil {
|
||||||
|
p.isValid = true
|
||||||
}
|
}
|
||||||
// blockedAccounts should be sorted by account BE bytes, but GetStorageItemsWithPrefix
|
return fErr
|
||||||
// returns values sorted by key (which is account's BE bytes), so don't need to sort
|
|
||||||
// one more time.
|
|
||||||
|
|
||||||
p.isValid = true
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFeePerByte is Policy contract method and returns required transaction's fee
|
// getFeePerByte is Policy contract method and returns required transaction's fee
|
||||||
|
|
|
@ -2,9 +2,3 @@ package state
|
||||||
|
|
||||||
// StorageItem is the value to be stored with read-only flag.
|
// StorageItem is the value to be stored with read-only flag.
|
||||||
type StorageItem []byte
|
type StorageItem []byte
|
||||||
|
|
||||||
// StorageItemWithKey is a storage item with corresponding key.
|
|
||||||
type StorageItemWithKey struct {
|
|
||||||
Key []byte
|
|
||||||
Item StorageItem
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue