Merge pull request #1239 from nspcc-dev/store_nep5_with_id
core: store contract IDs instead of hashes for NEP5Balances and Transfers
This commit is contained in:
commit
e4fc655115
12 changed files with 106 additions and 111 deletions
|
@ -742,8 +742,19 @@ func parseUint160(addr []byte) util.Uint160 {
|
|||
func (bc *Blockchain) processNEP5Transfer(cache *dao.Cached, h util.Uint256, b *block.Block, sc util.Uint160, from, to []byte, amount *big.Int) {
|
||||
toAddr := parseUint160(to)
|
||||
fromAddr := parseUint160(from)
|
||||
var id int32
|
||||
nativeContract := bc.contracts.ByHash(sc)
|
||||
if nativeContract != nil {
|
||||
id = nativeContract.Metadata().ContractID
|
||||
} else {
|
||||
assetContract := bc.GetContractState(sc)
|
||||
if assetContract == nil {
|
||||
return
|
||||
}
|
||||
id = assetContract.ID
|
||||
}
|
||||
transfer := &state.NEP5Transfer{
|
||||
Asset: sc,
|
||||
Asset: id,
|
||||
From: fromAddr,
|
||||
To: toAddr,
|
||||
Block: b.Index,
|
||||
|
@ -755,10 +766,10 @@ func (bc *Blockchain) processNEP5Transfer(cache *dao.Cached, h util.Uint256, b *
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
bs := balances.Trackers[sc]
|
||||
bs := balances.Trackers[id]
|
||||
bs.Balance = *new(big.Int).Sub(&bs.Balance, amount)
|
||||
bs.LastUpdatedBlock = b.Index
|
||||
balances.Trackers[sc] = bs
|
||||
balances.Trackers[id] = bs
|
||||
transfer.Amount = *new(big.Int).Sub(&transfer.Amount, amount)
|
||||
isBig, err := cache.AppendNEP5Transfer(fromAddr, balances.NextTransferBatch, transfer)
|
||||
if err != nil {
|
||||
|
@ -776,10 +787,10 @@ func (bc *Blockchain) processNEP5Transfer(cache *dao.Cached, h util.Uint256, b *
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
bs := balances.Trackers[sc]
|
||||
bs := balances.Trackers[id]
|
||||
bs.Balance = *new(big.Int).Add(&bs.Balance, amount)
|
||||
bs.LastUpdatedBlock = b.Index
|
||||
balances.Trackers[sc] = bs
|
||||
balances.Trackers[id] = bs
|
||||
|
||||
transfer.Amount = *amount
|
||||
isBig, err := cache.AppendNEP5Transfer(toAddr, balances.NextTransferBatch, transfer)
|
||||
|
@ -827,7 +838,7 @@ func (bc *Blockchain) GetUtilityTokenBalance(acc util.Uint160) *big.Int {
|
|||
if err != nil {
|
||||
return big.NewInt(0)
|
||||
}
|
||||
balance := bs.Trackers[bc.contracts.GAS.Hash].Balance
|
||||
balance := bs.Trackers[bc.contracts.GAS.ContractID].Balance
|
||||
return &balance
|
||||
}
|
||||
|
||||
|
@ -838,7 +849,7 @@ func (bc *Blockchain) GetGoverningTokenBalance(acc util.Uint160) (*big.Int, uint
|
|||
if err != nil {
|
||||
return big.NewInt(0), 0
|
||||
}
|
||||
neo := bs.Trackers[bc.contracts.NEO.Hash]
|
||||
neo := bs.Trackers[bc.contracts.NEO.ContractID]
|
||||
return &neo.Balance, neo.LastUpdatedBlock
|
||||
}
|
||||
|
||||
|
@ -1017,6 +1028,11 @@ func (bc *Blockchain) GetContractState(hash util.Uint160) *state.Contract {
|
|||
return contract
|
||||
}
|
||||
|
||||
// GetContractScriptHash returns contract script hash by its ID.
|
||||
func (bc *Blockchain) GetContractScriptHash(id int32) (util.Uint160, error) {
|
||||
return bc.dao.GetContractScriptHash(id)
|
||||
}
|
||||
|
||||
// GetAccountState returns the account state from its script hash.
|
||||
func (bc *Blockchain) GetAccountState(scriptHash util.Uint160) *state.Account {
|
||||
as, err := bc.dao.GetAccountState(scriptHash)
|
||||
|
|
|
@ -27,6 +27,7 @@ type Blockchainer interface {
|
|||
HeaderHeight() uint32
|
||||
GetBlock(hash util.Uint256) (*block.Block, error)
|
||||
GetContractState(hash util.Uint160) *state.Contract
|
||||
GetContractScriptHash(id int32) (util.Uint160, error)
|
||||
GetEnrollments() ([]state.Validator, error)
|
||||
GetGoverningTokenBalance(acc util.Uint160) (*big.Int, uint32)
|
||||
GetHeaderHash(int) util.Uint256
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
package dao
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
|
||||
"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/io"
|
||||
"github.com/nspcc-dev/neo-go/pkg/util"
|
||||
)
|
||||
|
@ -125,65 +123,6 @@ func (cd *Cached) AppendNEP5Transfer(acc util.Uint160, index uint32, tr *state.N
|
|||
return lg.Size() >= nep5TransferBatchSize, cd.PutNEP5TransferLog(acc, index, lg)
|
||||
}
|
||||
|
||||
// MigrateNEP5Balances migrates NEP5 balances from old contract to the new one.
|
||||
func (cd *Cached) MigrateNEP5Balances(from, to util.Uint160) error {
|
||||
var (
|
||||
simpleDAO *Simple
|
||||
cachedDAO = cd
|
||||
ok bool
|
||||
w = io.NewBufBinWriter()
|
||||
)
|
||||
for simpleDAO == nil {
|
||||
simpleDAO, ok = cachedDAO.DAO.(*Simple)
|
||||
if !ok {
|
||||
cachedDAO, ok = cachedDAO.DAO.(*Cached)
|
||||
if !ok {
|
||||
panic("uknown DAO")
|
||||
}
|
||||
}
|
||||
}
|
||||
for acc, bs := range cd.balances {
|
||||
err := simpleDAO.putNEP5Balances(acc, bs, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Reset()
|
||||
}
|
||||
cd.dropNEP5Cache = true
|
||||
var store = simpleDAO.Store
|
||||
// Create another layer of cache because we can't change original storage
|
||||
// while seeking.
|
||||
var upStore = storage.NewMemCachedStore(store)
|
||||
store.Seek([]byte{byte(storage.STNEP5Balances)}, func(k, v []byte) {
|
||||
if !bytes.Contains(v, from[:]) {
|
||||
return
|
||||
}
|
||||
bs := state.NewNEP5Balances()
|
||||
reader := io.NewBinReaderFromBuf(v)
|
||||
bs.DecodeBinary(reader)
|
||||
if reader.Err != nil {
|
||||
panic("bad nep5 balances")
|
||||
}
|
||||
tr, ok := bs.Trackers[from]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(bs.Trackers, from)
|
||||
bs.Trackers[to] = tr
|
||||
w.Reset()
|
||||
bs.EncodeBinary(w.BinWriter)
|
||||
if w.Err != nil {
|
||||
panic("error on nep5 balance encoding")
|
||||
}
|
||||
err := upStore.Put(k, w.Bytes())
|
||||
if err != nil {
|
||||
panic("can't put value in the DB")
|
||||
}
|
||||
})
|
||||
_, err := upStore.Persist()
|
||||
return err
|
||||
}
|
||||
|
||||
// Persist flushes all the changes made into the (supposedly) persistent
|
||||
// underlying store.
|
||||
func (cd *Cached) Persist() (int, error) {
|
||||
|
|
|
@ -28,6 +28,7 @@ type DAO interface {
|
|||
GetBatch() *storage.MemBatch
|
||||
GetBlock(hash util.Uint256) (*block.Block, error)
|
||||
GetContractState(hash util.Uint160) (*state.Contract, error)
|
||||
GetContractScriptHash(id int32) (util.Uint160, error)
|
||||
GetCurrentBlockHeight() (uint32, error)
|
||||
GetCurrentHeaderHeight() (i uint32, h util.Uint256, err error)
|
||||
GetCurrentStateRootHeight() (uint32, error)
|
||||
|
@ -171,7 +172,10 @@ func (dao *Simple) GetContractState(hash util.Uint160) (*state.Contract, error)
|
|||
// PutContractState puts given contract state into the given store.
|
||||
func (dao *Simple) PutContractState(cs *state.Contract) error {
|
||||
key := storage.AppendPrefix(storage.STContract, cs.ScriptHash().BytesBE())
|
||||
return dao.Put(cs, key)
|
||||
if err := dao.Put(cs, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.putContractScriptHash(cs)
|
||||
}
|
||||
|
||||
// DeleteContractState deletes given contract state in the given store.
|
||||
|
@ -195,6 +199,29 @@ func (dao *Simple) GetAndUpdateNextContractID() (int32, error) {
|
|||
return id, dao.Store.Put(key, data)
|
||||
}
|
||||
|
||||
// putContractScriptHash puts given contract script hash into the given store.
|
||||
// It's a private method because it should be used after PutContractState to keep
|
||||
// ID-Hash pair always up-to-date.
|
||||
func (dao *Simple) putContractScriptHash(cs *state.Contract) error {
|
||||
key := make([]byte, 5)
|
||||
key[0] = byte(storage.STContractID)
|
||||
binary.LittleEndian.PutUint32(key[1:], uint32(cs.ID))
|
||||
return dao.Store.Put(key, cs.ScriptHash().BytesBE())
|
||||
}
|
||||
|
||||
// GetContractScriptHash returns script hash of the contract with the specified ID.
|
||||
// Contract with the script hash may be destroyed.
|
||||
func (dao *Simple) GetContractScriptHash(id int32) (util.Uint160, error) {
|
||||
key := make([]byte, 5)
|
||||
key[0] = byte(storage.STContractID)
|
||||
binary.LittleEndian.PutUint32(key[1:], uint32(id))
|
||||
data := &util.Uint160{}
|
||||
if err := dao.GetAndDecode(data, key); err != nil {
|
||||
return *data, err
|
||||
}
|
||||
return *data, nil
|
||||
}
|
||||
|
||||
// -- end contracts.
|
||||
|
||||
// -- start nep5 balances.
|
||||
|
|
|
@ -154,7 +154,6 @@ func contractUpdate(ic *interop.Context, v *vm.VM) error {
|
|||
if err := ic.DAO.DeleteContractState(oldHash); err != nil {
|
||||
return fmt.Errorf("failed to update script: %v", err)
|
||||
}
|
||||
ic.DAO.MigrateNEP5Balances(oldHash, newHash)
|
||||
}
|
||||
// if manifest was provided, update the old contract manifest and check associated
|
||||
// storage items if needed
|
||||
|
|
|
@ -210,7 +210,7 @@ func (n *NEO) unclaimedGas(ic *interop.Context, args []stackitem.Item) stackitem
|
|||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tr := bs.Trackers[n.Hash]
|
||||
tr := bs.Trackers[n.ContractID]
|
||||
|
||||
gen := ic.Chain.CalculateClaimable(&tr.Balance, tr.LastUpdatedBlock, end)
|
||||
return stackitem.NewBigInteger(gen)
|
||||
|
|
|
@ -210,7 +210,7 @@ func (c *nep5TokenNative) balanceOf(ic *interop.Context, args []stackitem.Item)
|
|||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
balance := bs.Trackers[c.Hash].Balance
|
||||
balance := bs.Trackers[c.ContractID].Balance
|
||||
return stackitem.NewBigInteger(&balance)
|
||||
}
|
||||
|
||||
|
|
|
@ -26,8 +26,8 @@ type NEP5TransferLog struct {
|
|||
|
||||
// NEP5Transfer represents a single NEP5 Transfer event.
|
||||
type NEP5Transfer struct {
|
||||
// Asset is a NEP5 contract hash.
|
||||
Asset util.Uint160
|
||||
// Asset is a NEP5 contract ID.
|
||||
Asset int32
|
||||
// Address is the address of the sender.
|
||||
From util.Uint160
|
||||
// To is the address of the receiver.
|
||||
|
@ -43,10 +43,10 @@ type NEP5Transfer struct {
|
|||
Tx util.Uint256
|
||||
}
|
||||
|
||||
// NEP5Balances is a map of the NEP5 contract hashes
|
||||
// NEP5Balances is a map of the NEP5 contract IDs
|
||||
// to the corresponding structures.
|
||||
type NEP5Balances struct {
|
||||
Trackers map[util.Uint160]NEP5Tracker
|
||||
Trackers map[int32]NEP5Tracker
|
||||
// NextTransferBatch stores an index of the next transfer batch.
|
||||
NextTransferBatch uint32
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ type NEP5Balances struct {
|
|||
// NewNEP5Balances returns new NEP5Balances.
|
||||
func NewNEP5Balances() *NEP5Balances {
|
||||
return &NEP5Balances{
|
||||
Trackers: make(map[util.Uint160]NEP5Tracker),
|
||||
Trackers: make(map[int32]NEP5Tracker),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,11 +62,10 @@ func NewNEP5Balances() *NEP5Balances {
|
|||
func (bs *NEP5Balances) DecodeBinary(r *io.BinReader) {
|
||||
bs.NextTransferBatch = r.ReadU32LE()
|
||||
lenBalances := r.ReadVarUint()
|
||||
m := make(map[util.Uint160]NEP5Tracker, lenBalances)
|
||||
m := make(map[int32]NEP5Tracker, lenBalances)
|
||||
for i := 0; i < int(lenBalances); i++ {
|
||||
var key util.Uint160
|
||||
key := int32(r.ReadU32LE())
|
||||
var tr NEP5Tracker
|
||||
r.ReadBytes(key[:])
|
||||
tr.DecodeBinary(r)
|
||||
m[key] = tr
|
||||
}
|
||||
|
@ -78,7 +77,7 @@ func (bs *NEP5Balances) EncodeBinary(w *io.BinWriter) {
|
|||
w.WriteU32LE(bs.NextTransferBatch)
|
||||
w.WriteVarUint(uint64(len(bs.Trackers)))
|
||||
for k, v := range bs.Trackers {
|
||||
w.WriteBytes(k[:])
|
||||
w.WriteU32LE(uint32(k))
|
||||
v.EncodeBinary(w)
|
||||
}
|
||||
}
|
||||
|
@ -133,7 +132,7 @@ func (t *NEP5Tracker) DecodeBinary(r *io.BinReader) {
|
|||
|
||||
// EncodeBinary implements io.Serializable interface.
|
||||
func (t *NEP5Transfer) EncodeBinary(w *io.BinWriter) {
|
||||
w.WriteBytes(t.Asset[:])
|
||||
w.WriteU32LE(uint32(t.Asset))
|
||||
w.WriteBytes(t.Tx[:])
|
||||
w.WriteBytes(t.From[:])
|
||||
w.WriteBytes(t.To[:])
|
||||
|
@ -151,7 +150,7 @@ func (t *NEP5Transfer) DecodeBinary(r *io.BinReader) {
|
|||
|
||||
// DecodeBinaryReturnCount decodes NEP5Transfer and returns the number of bytes read.
|
||||
func (t *NEP5Transfer) DecodeBinaryReturnCount(r *io.BinReader) int {
|
||||
r.ReadBytes(t.Asset[:])
|
||||
t.Asset = int32(r.ReadU32LE())
|
||||
r.ReadBytes(t.Tx[:])
|
||||
r.ReadBytes(t.From[:])
|
||||
r.ReadBytes(t.To[:])
|
||||
|
@ -161,5 +160,5 @@ func (t *NEP5Transfer) DecodeBinaryReturnCount(r *io.BinReader) int {
|
|||
amountBytes := make([]byte, amountLen)
|
||||
r.ReadBytes(amountBytes)
|
||||
t.Amount = *bigint.FromBytes(amountBytes)
|
||||
return util.Uint160Size*3 + 8 + 4 + (8 + len(amountBytes)) + +util.Uint256Size
|
||||
return 4 + util.Uint160Size*2 + 8 + 4 + (8 + len(amountBytes)) + +util.Uint256Size
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ func TestNEP5Tracker_EncodeBinary(t *testing.T) {
|
|||
|
||||
func TestNEP5Transfer_DecodeBinary(t *testing.T) {
|
||||
expected := &NEP5Transfer{
|
||||
Asset: util.Uint160{1, 2, 3},
|
||||
Asset: 123,
|
||||
From: util.Uint160{5, 6, 7},
|
||||
To: util.Uint160{8, 9, 10},
|
||||
Amount: *big.NewInt(42),
|
||||
|
@ -78,7 +78,7 @@ func randomTransfer(r *rand.Rand) *NEP5Transfer {
|
|||
return &NEP5Transfer{
|
||||
Amount: *big.NewInt(int64(r.Uint64())),
|
||||
Block: r.Uint32(),
|
||||
Asset: random.Uint160(),
|
||||
Asset: int32(random.Int(10, 10000000)),
|
||||
From: random.Uint160(),
|
||||
To: random.Uint160(),
|
||||
Tx: random.Uint256(),
|
||||
|
|
|
@ -13,6 +13,7 @@ const (
|
|||
STAccount KeyPrefix = 0x40
|
||||
STNotification KeyPrefix = 0x4d
|
||||
STContract KeyPrefix = 0x50
|
||||
STContractID KeyPrefix = 0x51
|
||||
STStorage KeyPrefix = 0x70
|
||||
STNEP5Transfers KeyPrefix = 0x72
|
||||
STNEP5Balances KeyPrefix = 0x73
|
||||
|
|
|
@ -70,6 +70,9 @@ func (chain testChain) GetBlock(hash util.Uint256) (*block.Block, error) {
|
|||
func (chain testChain) GetContractState(hash util.Uint160) *state.Contract {
|
||||
panic("TODO")
|
||||
}
|
||||
func (chain testChain) GetContractScriptHash(id int32) (util.Uint160, error) {
|
||||
panic("TODO")
|
||||
}
|
||||
func (chain testChain) GetHeaderHash(int) util.Uint256 {
|
||||
return util.Uint256{}
|
||||
}
|
||||
|
|
|
@ -514,15 +514,15 @@ func (s *Server) getNEP5Balances(ps request.Params) (interface{}, *response.Erro
|
|||
Balances: []result.NEP5Balance{},
|
||||
}
|
||||
if as != nil {
|
||||
cache := make(map[util.Uint160]int64)
|
||||
for h, bal := range as.Trackers {
|
||||
dec, err := s.getDecimals(h, cache)
|
||||
cache := make(map[int32]decimals)
|
||||
for id, bal := range as.Trackers {
|
||||
dec, err := s.getDecimals(id, cache)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
amount := amountToString(&bal.Balance, dec)
|
||||
amount := amountToString(&bal.Balance, dec.Value)
|
||||
bs.Balances = append(bs.Balances, result.NEP5Balance{
|
||||
Asset: h,
|
||||
Asset: dec.Hash,
|
||||
Amount: amount,
|
||||
LastUpdated: bal.LastUpdatedBlock,
|
||||
})
|
||||
|
@ -543,20 +543,20 @@ func (s *Server) getNEP5Transfers(ps request.Params) (interface{}, *response.Err
|
|||
Sent: []result.NEP5Transfer{},
|
||||
}
|
||||
lg := s.chain.GetNEP5TransferLog(u)
|
||||
cache := make(map[util.Uint160]int64)
|
||||
cache := make(map[int32]decimals)
|
||||
err = lg.ForEach(func(tr *state.NEP5Transfer) error {
|
||||
transfer := result.NEP5Transfer{
|
||||
Timestamp: tr.Timestamp,
|
||||
Asset: tr.Asset,
|
||||
Index: tr.Block,
|
||||
TxHash: tr.Tx,
|
||||
}
|
||||
d, err := s.getDecimals(tr.Asset, cache)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
transfer := result.NEP5Transfer{
|
||||
Timestamp: tr.Timestamp,
|
||||
Asset: d.Hash,
|
||||
Index: tr.Block,
|
||||
TxHash: tr.Tx,
|
||||
}
|
||||
if tr.Amount.Sign() > 0 { // token was received
|
||||
transfer.Amount = amountToString(&tr.Amount, d)
|
||||
transfer.Amount = amountToString(&tr.Amount, d.Value)
|
||||
if !tr.From.Equals(util.Uint160{}) {
|
||||
transfer.Address = address.Uint160ToString(tr.From)
|
||||
}
|
||||
|
@ -564,7 +564,7 @@ func (s *Server) getNEP5Transfers(ps request.Params) (interface{}, *response.Err
|
|||
return nil
|
||||
}
|
||||
|
||||
transfer.Amount = amountToString(new(big.Int).Neg(&tr.Amount), d)
|
||||
transfer.Amount = amountToString(new(big.Int).Neg(&tr.Amount), d.Value)
|
||||
if !tr.To.Equals(util.Uint160{}) {
|
||||
transfer.Address = address.Uint160ToString(tr.To)
|
||||
}
|
||||
|
@ -590,10 +590,20 @@ func amountToString(amount *big.Int, decimals int64) string {
|
|||
return fmt.Sprintf(fs, q, r)
|
||||
}
|
||||
|
||||
func (s *Server) getDecimals(h util.Uint160, cache map[util.Uint160]int64) (int64, *response.Error) {
|
||||
if d, ok := cache[h]; ok {
|
||||
// decimals represents decimals value for the contract with the specified scripthash.
|
||||
type decimals struct {
|
||||
Hash util.Uint160
|
||||
Value int64
|
||||
}
|
||||
|
||||
func (s *Server) getDecimals(contractID int32, cache map[int32]decimals) (decimals, error) {
|
||||
if d, ok := cache[contractID]; ok {
|
||||
return d, nil
|
||||
}
|
||||
h, err := s.chain.GetContractScriptHash(contractID)
|
||||
if err != nil {
|
||||
return decimals{}, err
|
||||
}
|
||||
script, err := request.CreateFunctionInvocationScript(h, request.Params{
|
||||
{
|
||||
Type: request.StringT,
|
||||
|
@ -605,26 +615,26 @@ func (s *Server) getDecimals(h util.Uint160, cache map[util.Uint160]int64) (int6
|
|||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, response.NewInternalServerError("Can't create script", err)
|
||||
return decimals{}, fmt.Errorf("can't create script: %v", err)
|
||||
}
|
||||
res := s.runScriptInVM(script, nil)
|
||||
if res == nil || res.State != "HALT" || len(res.Stack) == 0 {
|
||||
return 0, response.NewInternalServerError("execution error", errors.New("no result"))
|
||||
return decimals{}, errors.New("execution error : no result")
|
||||
}
|
||||
|
||||
var d int64
|
||||
d := decimals{Hash: h}
|
||||
switch item := res.Stack[len(res.Stack)-1]; item.Type {
|
||||
case smartcontract.IntegerType:
|
||||
d = item.Value.(int64)
|
||||
d.Value = item.Value.(int64)
|
||||
case smartcontract.ByteArrayType:
|
||||
d = bigint.FromBytes(item.Value.([]byte)).Int64()
|
||||
d.Value = bigint.FromBytes(item.Value.([]byte)).Int64()
|
||||
default:
|
||||
return 0, response.NewInternalServerError("invalid result", errors.New("not an integer"))
|
||||
return d, errors.New("invalid result: not an integer")
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, response.NewInternalServerError("incorrect result", errors.New("negative result"))
|
||||
if d.Value < 0 {
|
||||
return d, errors.New("incorrect result: negative result")
|
||||
}
|
||||
cache[h] = d
|
||||
cache[contractID] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue