mirror of
https://github.com/nspcc-dev/neo-go.git
synced 2024-11-29 03:41:45 +00:00
rpcsrv: execute all witnesses for calculatenetworkfee
Try to get as much data as possible, fix #2654.
This commit is contained in:
parent
a2c4a7f611
commit
03cc9b2762
5 changed files with 225 additions and 18 deletions
15
docs/rpc.md
15
docs/rpc.md
|
@ -98,6 +98,21 @@ following data types:
|
||||||
|
|
||||||
Any call that takes any of these types for input in JSON format is affected.
|
Any call that takes any of these types for input in JSON format is affected.
|
||||||
|
|
||||||
|
##### `calculatenetworkfee`
|
||||||
|
|
||||||
|
NeoGo tries to cover more cases with its calculatenetworkfee implementation,
|
||||||
|
whereas C# node support only standard signature contracts and deployed
|
||||||
|
contracts that can execute `verify` successfully on incomplete (not yet signed
|
||||||
|
properly) transaction, NeoGo also works with deployed contracts that fail at
|
||||||
|
this stage and executes non-standard contracts (that can fail
|
||||||
|
too). It's ignoring the result of any verification script (since the method
|
||||||
|
calculates fee and doesn't care about transaction validity). Invocation script
|
||||||
|
is used as is when provided, but absent it the system will try to infer one
|
||||||
|
based on the `verify` method signature (pushing dummy signatures or
|
||||||
|
hashes). If signature has some types which contents can't be adequately
|
||||||
|
guessed (arrays, maps, interop, void) they're ignored. See
|
||||||
|
neo-project/neo#2805 as well.
|
||||||
|
|
||||||
##### `invokefunction`, `invokescript`
|
##### `invokefunction`, `invokescript`
|
||||||
|
|
||||||
neo-go implementation of `invokefunction` does not return `tx`
|
neo-go implementation of `invokefunction` does not return `tx`
|
||||||
|
|
|
@ -25,7 +25,6 @@ import (
|
||||||
"github.com/nspcc-dev/neo-go/pkg/config/netmode"
|
"github.com/nspcc-dev/neo-go/pkg/config/netmode"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core"
|
"github.com/nspcc-dev/neo-go/pkg/core"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/block"
|
"github.com/nspcc-dev/neo-go/pkg/core/block"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/fee"
|
|
||||||
"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/interop/iterator"
|
"github.com/nspcc-dev/neo-go/pkg/core/interop/iterator"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/core/mempool"
|
"github.com/nspcc-dev/neo-go/pkg/core/mempool"
|
||||||
|
@ -45,9 +44,12 @@ import (
|
||||||
"github.com/nspcc-dev/neo-go/pkg/network/payload"
|
"github.com/nspcc-dev/neo-go/pkg/network/payload"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/services/oracle/broadcaster"
|
"github.com/nspcc-dev/neo-go/pkg/services/oracle/broadcaster"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/services/rpcsrv/params"
|
"github.com/nspcc-dev/neo-go/pkg/services/rpcsrv/params"
|
||||||
|
"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"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger"
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/util"
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/vm"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/vm/emit"
|
"github.com/nspcc-dev/neo-go/pkg/vm/emit"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/vm/opcode"
|
"github.com/nspcc-dev/neo-go/pkg/vm/opcode"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
||||||
|
@ -758,26 +760,43 @@ func (s *Server) calculateNetworkFee(reqParams params.Params) (interface{}, *neo
|
||||||
return 0, neorpc.WrapErrorWithData(neorpc.ErrInvalidParams, fmt.Sprintf("failed to compute tx size: %s", err))
|
return 0, neorpc.WrapErrorWithData(neorpc.ErrInvalidParams, fmt.Sprintf("failed to compute tx size: %s", err))
|
||||||
}
|
}
|
||||||
size := len(hashablePart) + io.GetVarSize(len(tx.Signers))
|
size := len(hashablePart) + io.GetVarSize(len(tx.Signers))
|
||||||
var (
|
var netFee int64
|
||||||
ef int64
|
|
||||||
netFee int64
|
|
||||||
)
|
|
||||||
for i, signer := range tx.Signers {
|
for i, signer := range tx.Signers {
|
||||||
w := tx.Scripts[i]
|
w := tx.Scripts[i]
|
||||||
if len(w.VerificationScript) == 0 { // then it still might be a contract-based verification
|
if len(w.InvocationScript) == 0 { // No invocation provided, try to infer one.
|
||||||
gasConsumed, _ := s.chain.VerifyWitness(signer.Account, tx, &tx.Scripts[i], int64(s.config.MaxGasInvoke))
|
var paramz []manifest.Parameter
|
||||||
netFee += gasConsumed
|
if len(w.VerificationScript) == 0 { // Contract-based verification
|
||||||
size += io.GetVarSize([]byte{}) + // verification script is empty (contract-based witness)
|
cs := s.chain.GetContractState(signer.Account)
|
||||||
io.GetVarSize(tx.Scripts[i].InvocationScript) // invocation script might not be empty (args for `verify`)
|
if cs == nil {
|
||||||
continue
|
return 0, neorpc.WrapErrorWithData(neorpc.ErrInvalidParams, fmt.Sprintf("signer %d has no verification script and no deployed contract", i))
|
||||||
|
}
|
||||||
|
md := cs.Manifest.ABI.GetMethod(manifest.MethodVerify, -1)
|
||||||
|
if md == nil || md.ReturnType != smartcontract.BoolType {
|
||||||
|
return 0, neorpc.WrapErrorWithData(neorpc.ErrInvalidParams, fmt.Sprintf("signer %d has no verify method in deployed contract", i))
|
||||||
|
}
|
||||||
|
paramz = md.Parameters // Might as well have none params and it's OK.
|
||||||
|
} else { // Regular signature verification.
|
||||||
|
if vm.IsSignatureContract(w.VerificationScript) {
|
||||||
|
paramz = []manifest.Parameter{{Type: smartcontract.SignatureType}}
|
||||||
|
} else if nSigs, _, ok := vm.ParseMultiSigContract(w.VerificationScript); ok {
|
||||||
|
paramz = make([]manifest.Parameter, nSigs)
|
||||||
|
for j := 0; j < nSigs; j++ {
|
||||||
|
paramz[j] = manifest.Parameter{Type: smartcontract.SignatureType}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inv := io.NewBufBinWriter()
|
||||||
|
for _, p := range paramz {
|
||||||
|
p.Type.EncodeDefaultValue(inv.BinWriter)
|
||||||
|
}
|
||||||
|
if inv.Err != nil {
|
||||||
|
return nil, neorpc.NewInternalServerError(fmt.Sprintf("failed to create dummy invocation script (signer %d): %s", i, inv.Err.Error()))
|
||||||
|
}
|
||||||
|
w.InvocationScript = inv.Bytes()
|
||||||
}
|
}
|
||||||
|
gasConsumed, _ := s.chain.VerifyWitness(signer.Account, tx, &w, int64(s.config.MaxGasInvoke))
|
||||||
if ef == 0 {
|
netFee += gasConsumed
|
||||||
ef = s.chain.GetBaseExecFee()
|
size += io.GetVarSize(w.VerificationScript) + io.GetVarSize(w.InvocationScript)
|
||||||
}
|
|
||||||
fee, sizeDelta := fee.Calculate(ef, w.VerificationScript)
|
|
||||||
netFee += fee
|
|
||||||
size += sizeDelta
|
|
||||||
}
|
}
|
||||||
if s.chain.P2PSigExtensionsEnabled() {
|
if s.chain.P2PSigExtensionsEnabled() {
|
||||||
attrs := tx.GetAttributes(transaction.NotaryAssistedT)
|
attrs := tx.GetAttributes(transaction.NotaryAssistedT)
|
||||||
|
|
|
@ -39,6 +39,7 @@ import (
|
||||||
"github.com/nspcc-dev/neo-go/pkg/network/payload"
|
"github.com/nspcc-dev/neo-go/pkg/network/payload"
|
||||||
rpc2 "github.com/nspcc-dev/neo-go/pkg/services/oracle/broadcaster"
|
rpc2 "github.com/nspcc-dev/neo-go/pkg/services/oracle/broadcaster"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/services/rpcsrv/params"
|
"github.com/nspcc-dev/neo-go/pkg/services/rpcsrv/params"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger"
|
"github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/util"
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/vm/emit"
|
"github.com/nspcc-dev/neo-go/pkg/vm/emit"
|
||||||
|
@ -2453,6 +2454,122 @@ func testRPCProtocol(t *testing.T, doRPCCall func(string, string, *testing.T) []
|
||||||
}, 2*time.Duration(rpcSrv.config.SessionExpirationTime)*time.Second, 10*time.Millisecond)
|
}, 2*time.Duration(rpcSrv.config.SessionExpirationTime)*time.Second, 10*time.Millisecond)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
t.Run("calculatenetworkfee", func(t *testing.T) {
|
||||||
|
t.Run("no parameters", func(t *testing.T) {
|
||||||
|
body := doRPCCall(`{"jsonrpc": "2.0", "id": 1, "method": "calculatenetworkfee", "params": []}"`, httpSrv.URL, t)
|
||||||
|
_ = checkErrGetResult(t, body, true, "Invalid Params")
|
||||||
|
})
|
||||||
|
t.Run("non-base64 parameter", func(t *testing.T) {
|
||||||
|
body := doRPCCall(`{"jsonrpc": "2.0", "id": 1, "method": "calculatenetworkfee", "params": ["noatbase64"]}"`, httpSrv.URL, t)
|
||||||
|
_ = checkErrGetResult(t, body, true, "Invalid Params")
|
||||||
|
})
|
||||||
|
t.Run("non-transaction parameter", func(t *testing.T) {
|
||||||
|
body := doRPCCall(`{"jsonrpc": "2.0", "id": 1, "method": "calculatenetworkfee", "params": ["bm90IGEgdHJhbnNhY3Rpb24K"]}"`, httpSrv.URL, t)
|
||||||
|
_ = checkErrGetResult(t, body, true, "Invalid Params")
|
||||||
|
})
|
||||||
|
calcReq := func(t *testing.T, tx *transaction.Transaction) []byte {
|
||||||
|
rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "calculatenetworkfee", "params": ["%s"]}"`, base64.StdEncoding.EncodeToString(tx.Bytes()))
|
||||||
|
return doRPCCall(rpc, httpSrv.URL, t)
|
||||||
|
}
|
||||||
|
t.Run("non-contract with zero verification", func(t *testing.T) {
|
||||||
|
tx := &transaction.Transaction{
|
||||||
|
Script: []byte{byte(opcode.RET)},
|
||||||
|
Signers: []transaction.Signer{{Account: util.Uint160{1, 2, 3}, Scopes: transaction.CalledByEntry}},
|
||||||
|
Scripts: []transaction.Witness{{
|
||||||
|
InvocationScript: []byte{},
|
||||||
|
VerificationScript: []byte{},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
body := calcReq(t, tx)
|
||||||
|
_ = checkErrGetResult(t, body, true, "signer 0 has no verification script and no deployed contract")
|
||||||
|
})
|
||||||
|
t.Run("contract with no verify", func(t *testing.T) {
|
||||||
|
tx := &transaction.Transaction{
|
||||||
|
Script: []byte{byte(opcode.RET)},
|
||||||
|
Signers: []transaction.Signer{{Account: nnsHash, Scopes: transaction.CalledByEntry}},
|
||||||
|
Scripts: []transaction.Witness{{
|
||||||
|
InvocationScript: []byte{},
|
||||||
|
VerificationScript: []byte{},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
body := calcReq(t, tx)
|
||||||
|
_ = checkErrGetResult(t, body, true, "signer 0 has no verify method in deployed contract")
|
||||||
|
})
|
||||||
|
checkCalc := func(t *testing.T, tx *transaction.Transaction, fee int64) {
|
||||||
|
resp := checkErrGetResult(t, calcReq(t, tx), false)
|
||||||
|
res := new(result.NetworkFee)
|
||||||
|
require.NoError(t, json.Unmarshal(resp, res))
|
||||||
|
require.Equal(t, fee, res.Value)
|
||||||
|
}
|
||||||
|
t.Run("simple GAS transfer", func(t *testing.T) {
|
||||||
|
priv0 := testchain.PrivateKeyByID(0)
|
||||||
|
script, err := smartcontract.CreateCallWithAssertScript(chain.UtilityTokenHash(), "transfer",
|
||||||
|
priv0.GetScriptHash(), priv0.GetScriptHash(), 1, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tx := &transaction.Transaction{
|
||||||
|
Script: script,
|
||||||
|
Signers: []transaction.Signer{{Account: priv0.GetScriptHash(), Scopes: transaction.CalledByEntry}},
|
||||||
|
Scripts: []transaction.Witness{{
|
||||||
|
InvocationScript: []byte{},
|
||||||
|
VerificationScript: priv0.PublicKey().GetVerificationScript(),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
checkCalc(t, tx, 1228520) // Perfectly matches FeeIsSignatureContractDetailed() C# test.
|
||||||
|
})
|
||||||
|
t.Run("multisignature tx", func(t *testing.T) {
|
||||||
|
priv0 := testchain.PrivateKeyByID(0)
|
||||||
|
priv1 := testchain.PrivateKeyByID(1)
|
||||||
|
accScript, err := smartcontract.CreateDefaultMultiSigRedeemScript(keys.PublicKeys{priv0.PublicKey(), priv1.PublicKey()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
multiAcc := hash.Hash160(accScript)
|
||||||
|
txScript, err := smartcontract.CreateCallWithAssertScript(chain.UtilityTokenHash(), "transfer",
|
||||||
|
multiAcc, priv0.GetScriptHash(), 1, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tx := &transaction.Transaction{
|
||||||
|
Script: txScript,
|
||||||
|
Signers: []transaction.Signer{{Account: multiAcc, Scopes: transaction.CalledByEntry}},
|
||||||
|
Scripts: []transaction.Witness{{
|
||||||
|
InvocationScript: []byte{},
|
||||||
|
VerificationScript: accScript,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
checkCalc(t, tx, 2315100) // Perfectly matches FeeIsMultiSigContract() C# test.
|
||||||
|
})
|
||||||
|
checkContract := func(t *testing.T, verAcc util.Uint160, invoc []byte, fee int64) {
|
||||||
|
txScript, err := smartcontract.CreateCallWithAssertScript(chain.UtilityTokenHash(), "transfer",
|
||||||
|
verAcc, verAcc, 1, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tx := &transaction.Transaction{
|
||||||
|
Script: txScript,
|
||||||
|
Signers: []transaction.Signer{{Account: verAcc, Scopes: transaction.CalledByEntry}},
|
||||||
|
Scripts: []transaction.Witness{{
|
||||||
|
InvocationScript: invoc,
|
||||||
|
VerificationScript: []byte{},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
checkCalc(t, tx, fee)
|
||||||
|
}
|
||||||
|
t.Run("contract-based verification", func(t *testing.T) {
|
||||||
|
verAcc, err := util.Uint160DecodeStringLE(verifyContractHash)
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkContract(t, verAcc, []byte{}, 636610) // No C# match, but we believe it's OK.
|
||||||
|
})
|
||||||
|
t.Run("contract-based verification with parameters", func(t *testing.T) {
|
||||||
|
verAcc, err := util.Uint160DecodeStringLE(verifyWithArgsContractHash)
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkContract(t, verAcc, []byte{}, 737530) // No C# match, but we believe it's OK and it differs from the one above.
|
||||||
|
})
|
||||||
|
t.Run("contract-based verification with invocation script", func(t *testing.T) {
|
||||||
|
verAcc, err := util.Uint160DecodeStringLE(verifyWithArgsContractHash)
|
||||||
|
require.NoError(t, err)
|
||||||
|
invocWriter := io.NewBufBinWriter()
|
||||||
|
emit.Bool(invocWriter.BinWriter, false)
|
||||||
|
emit.Int(invocWriter.BinWriter, 5)
|
||||||
|
emit.String(invocWriter.BinWriter, "")
|
||||||
|
invocScript := invocWriter.Bytes()
|
||||||
|
checkContract(t, verAcc, invocScript, 640360) // No C# match, but we believe it's OK and it has a specific invocation script overriding anything server-side.
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *executor) getHeader(s string) *block.Header {
|
func (e *executor) getHeader(s string) *block.Header {
|
||||||
|
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"github.com/nspcc-dev/neo-go/pkg/encoding/address"
|
"github.com/nspcc-dev/neo-go/pkg/encoding/address"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/io"
|
"github.com/nspcc-dev/neo-go/pkg/io"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/util"
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/vm/emit"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/vm/opcode"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -139,6 +141,35 @@ func (pt *ParamType) DecodeBinary(r *io.BinReader) {
|
||||||
*pt = ParamType(r.ReadB())
|
*pt = ParamType(r.ReadB())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EncodeDefaultValue writes a script to push the default parameter value onto
|
||||||
|
// the evaluation stack into the given writer. It's mostly useful for constructing
|
||||||
|
// dummy invocation scripts when parameter types are known, but they can't be
|
||||||
|
// filled in. A best effort approach is used, it can't be perfect since for many
|
||||||
|
// types the exact values can be arbitrarily long, but it tries to do something
|
||||||
|
// reasonable in each case. For signatures, strings, arrays and "any" type a 64-byte
|
||||||
|
// zero-filled value is used, hash160 and hash256 use appropriately sized values,
|
||||||
|
// public key is represented by 33-byte value while 32 bytes are used for integer
|
||||||
|
// and a simple push+convert is used for boolean. Other types produce no code at all.
|
||||||
|
func (pt ParamType) EncodeDefaultValue(w *io.BinWriter) {
|
||||||
|
var b [64]byte
|
||||||
|
|
||||||
|
switch pt {
|
||||||
|
case AnyType, SignatureType, StringType, ByteArrayType:
|
||||||
|
emit.Bytes(w, b[:])
|
||||||
|
case BoolType:
|
||||||
|
emit.Bool(w, true)
|
||||||
|
case IntegerType:
|
||||||
|
emit.Instruction(w, opcode.PUSHINT256, b[:32])
|
||||||
|
case Hash160Type:
|
||||||
|
emit.Bytes(w, b[:20])
|
||||||
|
case Hash256Type:
|
||||||
|
emit.Bytes(w, b[:32])
|
||||||
|
case PublicKeyType:
|
||||||
|
emit.Bytes(w, b[:33])
|
||||||
|
case ArrayType, MapType, InteropInterfaceType, VoidType:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ParseParamType is a user-friendly string to ParamType converter, it's
|
// ParseParamType is a user-friendly string to ParamType converter, it's
|
||||||
// case-insensitive and makes the following conversions:
|
// case-insensitive and makes the following conversions:
|
||||||
//
|
//
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"math/big"
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/io"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/util"
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
||||||
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -335,6 +336,30 @@ func TestAdjustValToType(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEncodeDefaultValue(t *testing.T) {
|
||||||
|
for p, l := range map[ParamType]int{
|
||||||
|
UnknownType: 0,
|
||||||
|
AnyType: 66,
|
||||||
|
BoolType: 3,
|
||||||
|
IntegerType: 33,
|
||||||
|
ByteArrayType: 66,
|
||||||
|
StringType: 66,
|
||||||
|
Hash160Type: 22,
|
||||||
|
Hash256Type: 34,
|
||||||
|
PublicKeyType: 35,
|
||||||
|
SignatureType: 66,
|
||||||
|
ArrayType: 0,
|
||||||
|
MapType: 0,
|
||||||
|
InteropInterfaceType: 0,
|
||||||
|
VoidType: 0,
|
||||||
|
} {
|
||||||
|
b := io.NewBufBinWriter()
|
||||||
|
p.EncodeDefaultValue(b.BinWriter)
|
||||||
|
require.NoError(t, b.Err)
|
||||||
|
require.Equalf(t, l, len(b.Bytes()), p.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustHex(s string) []byte {
|
func mustHex(s string) []byte {
|
||||||
b, err := hex.DecodeString(s)
|
b, err := hex.DecodeString(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
Loading…
Reference in a new issue