2019-07-08 23:48:49 +00:00
|
|
|
package miner
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2019-08-20 16:50:17 +00:00
|
|
|
"sync"
|
2019-07-08 23:48:49 +00:00
|
|
|
"time"
|
|
|
|
|
2019-10-18 04:47:41 +00:00
|
|
|
"github.com/filecoin-project/lotus/build"
|
|
|
|
"github.com/filecoin-project/lotus/chain/actors"
|
|
|
|
"github.com/filecoin-project/lotus/chain/address"
|
|
|
|
"github.com/filecoin-project/lotus/chain/gen"
|
|
|
|
"github.com/filecoin-project/lotus/chain/types"
|
|
|
|
"github.com/filecoin-project/lotus/node/impl/full"
|
2019-08-20 16:50:17 +00:00
|
|
|
|
|
|
|
logging "github.com/ipfs/go-log"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"go.opencensus.io/trace"
|
|
|
|
"go.uber.org/fx"
|
|
|
|
"golang.org/x/xerrors"
|
2019-07-08 23:48:49 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var log = logging.Logger("miner")
|
|
|
|
|
2019-10-09 04:38:59 +00:00
|
|
|
type waitFunc func(ctx context.Context) error
|
2019-09-23 15:27:30 +00:00
|
|
|
|
2019-08-20 16:50:17 +00:00
|
|
|
type api struct {
|
|
|
|
fx.In
|
2019-07-08 23:48:49 +00:00
|
|
|
|
2019-08-20 16:50:17 +00:00
|
|
|
full.ChainAPI
|
2019-10-14 14:21:37 +00:00
|
|
|
full.SyncAPI
|
2019-08-20 16:50:17 +00:00
|
|
|
full.MpoolAPI
|
|
|
|
full.WalletAPI
|
2019-08-21 16:31:14 +00:00
|
|
|
full.StateAPI
|
2019-07-11 02:36:43 +00:00
|
|
|
}
|
|
|
|
|
2019-08-20 16:50:17 +00:00
|
|
|
func NewMiner(api api) *Miner {
|
2019-07-11 02:36:43 +00:00
|
|
|
return &Miner{
|
2019-09-23 15:27:30 +00:00
|
|
|
api: api,
|
2019-10-09 04:38:59 +00:00
|
|
|
waitFunc: func(ctx context.Context) error {
|
|
|
|
// Wait around for half the block time in case other parents come in
|
|
|
|
time.Sleep(build.BlockDelay * time.Second / 2)
|
|
|
|
return nil
|
|
|
|
},
|
2019-07-11 02:36:43 +00:00
|
|
|
}
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type Miner struct {
|
|
|
|
api api
|
|
|
|
|
2019-08-20 16:50:17 +00:00
|
|
|
lk sync.Mutex
|
|
|
|
addresses []address.Address
|
2019-08-20 18:05:17 +00:00
|
|
|
stop chan struct{}
|
|
|
|
stopping chan struct{}
|
2019-07-11 02:36:43 +00:00
|
|
|
|
2019-10-09 04:38:59 +00:00
|
|
|
waitFunc waitFunc
|
2019-07-08 23:48:49 +00:00
|
|
|
|
|
|
|
lastWork *MiningBase
|
|
|
|
}
|
|
|
|
|
2019-08-21 15:14:38 +00:00
|
|
|
func (m *Miner) Addresses() ([]address.Address, error) {
|
|
|
|
m.lk.Lock()
|
|
|
|
defer m.lk.Unlock()
|
|
|
|
|
|
|
|
out := make([]address.Address, len(m.addresses))
|
|
|
|
copy(out, m.addresses)
|
|
|
|
|
|
|
|
return out, nil
|
|
|
|
}
|
|
|
|
|
2019-08-20 16:50:17 +00:00
|
|
|
func (m *Miner) Register(addr address.Address) error {
|
|
|
|
m.lk.Lock()
|
|
|
|
defer m.lk.Unlock()
|
|
|
|
|
|
|
|
if len(m.addresses) > 0 {
|
2019-11-18 21:59:31 +00:00
|
|
|
for _, a := range m.addresses {
|
|
|
|
if a == addr {
|
|
|
|
log.Warnf("miner.Register called more than once for actor '%s'", addr)
|
|
|
|
return xerrors.Errorf("miner.Register called more than once for actor '%s'", addr)
|
|
|
|
}
|
2019-08-20 17:19:24 +00:00
|
|
|
}
|
2019-08-20 16:50:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
m.addresses = append(m.addresses, addr)
|
2019-11-18 21:59:31 +00:00
|
|
|
if len(m.addresses) == 1 {
|
|
|
|
m.stop = make(chan struct{})
|
|
|
|
go m.mine(context.TODO())
|
|
|
|
}
|
2019-08-20 16:50:17 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-08-20 18:05:17 +00:00
|
|
|
func (m *Miner) Unregister(ctx context.Context, addr address.Address) error {
|
|
|
|
m.lk.Lock()
|
2019-11-19 23:21:54 +00:00
|
|
|
defer m.lk.Unlock()
|
2019-08-20 18:05:17 +00:00
|
|
|
if len(m.addresses) == 0 {
|
|
|
|
return xerrors.New("no addresses registered")
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
idx := -1
|
2019-08-20 18:05:17 +00:00
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
for i, a := range m.addresses {
|
|
|
|
if a == addr {
|
|
|
|
idx = i
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if idx == -1 {
|
2019-08-20 18:05:17 +00:00
|
|
|
return xerrors.New("unregister: address not found")
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
m.addresses[idx] = m.addresses[len(m.addresses)-1]
|
|
|
|
m.addresses = m.addresses[:len(m.addresses)-1]
|
|
|
|
|
2019-08-20 18:05:17 +00:00
|
|
|
// Unregistering last address, stop mining first
|
2019-11-18 21:59:31 +00:00
|
|
|
if len(m.addresses) == 0 && m.stop != nil {
|
2019-11-19 23:21:54 +00:00
|
|
|
m.stopping = make(chan struct{})
|
2019-08-20 18:05:17 +00:00
|
|
|
stopping := m.stopping
|
2019-11-19 23:21:54 +00:00
|
|
|
close(m.stop)
|
|
|
|
|
2019-08-20 18:05:17 +00:00
|
|
|
select {
|
|
|
|
case <-stopping:
|
|
|
|
case <-ctx.Done():
|
|
|
|
return ctx.Err()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2019-08-20 16:50:17 +00:00
|
|
|
func (m *Miner) mine(ctx context.Context) {
|
2019-07-26 19:01:02 +00:00
|
|
|
ctx, span := trace.StartSpan(ctx, "/mine")
|
|
|
|
defer span.End()
|
2019-08-20 18:05:17 +00:00
|
|
|
|
2019-10-10 02:03:42 +00:00
|
|
|
var lastBase MiningBase
|
2019-10-10 00:38:39 +00:00
|
|
|
|
2019-11-19 19:36:03 +00:00
|
|
|
eventLoop:
|
2019-07-08 23:48:49 +00:00
|
|
|
for {
|
2019-08-20 18:05:17 +00:00
|
|
|
select {
|
|
|
|
case <-m.stop:
|
2019-11-19 23:21:54 +00:00
|
|
|
stopping := m.stopping
|
2019-08-20 18:05:17 +00:00
|
|
|
m.stop = nil
|
|
|
|
m.stopping = nil
|
2019-11-19 23:21:54 +00:00
|
|
|
close(stopping)
|
2019-08-20 18:17:59 +00:00
|
|
|
return
|
2019-11-19 23:21:54 +00:00
|
|
|
|
2019-08-20 18:05:17 +00:00
|
|
|
default:
|
|
|
|
}
|
2019-10-09 09:18:33 +00:00
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
m.lk.Lock()
|
|
|
|
addrs := m.addresses
|
|
|
|
m.lk.Unlock()
|
|
|
|
|
2019-10-09 09:18:33 +00:00
|
|
|
// Sleep a small amount in order to wait for other blocks to arrive
|
2019-10-09 04:38:59 +00:00
|
|
|
if err := m.waitFunc(ctx); err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return
|
|
|
|
}
|
2019-08-20 18:05:17 +00:00
|
|
|
|
2019-10-15 05:00:30 +00:00
|
|
|
base, err := m.GetBestMiningCandidate(ctx)
|
2019-07-08 23:48:49 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Errorf("failed to get best mining candidate: %s", err)
|
|
|
|
continue
|
|
|
|
}
|
2019-10-10 02:03:42 +00:00
|
|
|
if base.ts.Equals(lastBase.ts) && len(lastBase.tickets) == len(base.tickets) {
|
2019-10-15 05:07:28 +00:00
|
|
|
log.Errorf("BestMiningCandidate from the previous round: %s (tkts:%d)", lastBase.ts.Cids(), len(lastBase.tickets))
|
2019-10-10 02:03:42 +00:00
|
|
|
time.Sleep(build.BlockDelay * time.Second)
|
2019-10-10 00:38:39 +00:00
|
|
|
continue
|
|
|
|
}
|
2019-10-10 02:03:42 +00:00
|
|
|
lastBase = *base
|
2019-07-08 23:48:49 +00:00
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
blks := make([]*types.BlockMsg, 0)
|
|
|
|
|
|
|
|
for _, addr := range addrs {
|
|
|
|
b, err := m.mineOne(ctx, addr, base)
|
|
|
|
if err != nil {
|
|
|
|
log.Errorf("mining block failed: %s", err)
|
|
|
|
continue
|
|
|
|
}
|
2019-11-19 01:05:51 +00:00
|
|
|
if b != nil {
|
|
|
|
blks = append(blks, b)
|
|
|
|
}
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
if len(blks) != 0 {
|
|
|
|
btime := time.Unix(int64(blks[0].Header.Timestamp), 0)
|
2019-10-09 09:11:41 +00:00
|
|
|
if time.Now().Before(btime) {
|
|
|
|
time.Sleep(time.Until(btime))
|
2019-10-10 00:20:47 +00:00
|
|
|
} else {
|
2019-10-14 13:51:51 +00:00
|
|
|
log.Warnw("mined block in the past", "block-time", btime,
|
|
|
|
"time", time.Now(), "duration", time.Now().Sub(btime))
|
2019-10-09 09:11:41 +00:00
|
|
|
}
|
|
|
|
|
2019-11-19 19:36:03 +00:00
|
|
|
mWon := make(map[address.Address]struct{})
|
|
|
|
for _, b := range blks {
|
|
|
|
_, notOk := mWon[b.Header.Miner]
|
|
|
|
if notOk {
|
|
|
|
log.Errorw("2 blocks for the same miner. Throwing hands in the air. Report this. It is important.", "bloks", blks)
|
|
|
|
continue eventLoop
|
|
|
|
}
|
|
|
|
mWon[b.Header.Miner] = struct{}{}
|
|
|
|
}
|
2019-11-18 21:59:31 +00:00
|
|
|
for _, b := range blks {
|
|
|
|
if err := m.api.SyncSubmitBlock(ctx, b); err != nil {
|
|
|
|
log.Errorf("failed to submit newly mined block: %s", err)
|
|
|
|
}
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
2019-10-09 09:18:33 +00:00
|
|
|
} else {
|
|
|
|
nextRound := time.Unix(int64(base.ts.MinTimestamp()+uint64(build.BlockDelay*len(base.tickets))), 0)
|
|
|
|
time.Sleep(time.Until(nextRound))
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type MiningBase struct {
|
2019-07-26 04:54:22 +00:00
|
|
|
ts *types.TipSet
|
2019-08-15 02:30:21 +00:00
|
|
|
tickets []*types.Ticket
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
|
|
|
|
2019-10-15 05:00:30 +00:00
|
|
|
func (m *Miner) GetBestMiningCandidate(ctx context.Context) (*MiningBase, error) {
|
|
|
|
bts, err := m.api.ChainHead(ctx)
|
2019-07-08 23:48:49 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if m.lastWork != nil {
|
|
|
|
if m.lastWork.ts.Equals(bts) {
|
|
|
|
return m.lastWork, nil
|
|
|
|
}
|
|
|
|
|
2019-10-15 05:00:30 +00:00
|
|
|
btsw, err := m.api.ChainTipSetWeight(ctx, bts)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
ltsw, err := m.api.ChainTipSetWeight(ctx, m.lastWork.ts)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if types.BigCmp(btsw, ltsw) <= 0 {
|
2019-07-08 23:48:49 +00:00
|
|
|
return m.lastWork, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return &MiningBase{
|
|
|
|
ts: bts,
|
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
func (m *Miner) mineOne(ctx context.Context, addr address.Address, base *MiningBase) (*types.BlockMsg, error) {
|
2019-11-07 00:18:06 +00:00
|
|
|
log.Debugw("attempting to mine a block", "tipset", types.LogCids(base.ts.Cids()))
|
2019-11-18 21:59:31 +00:00
|
|
|
ticket, err := m.scratchTicket(ctx, addr, base)
|
2019-07-08 23:48:49 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "scratching ticket failed")
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
win, proof, err := gen.IsRoundWinner(ctx, base.ts, append(base.tickets, ticket), addr, &m.api)
|
2019-07-08 23:48:49 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "failed to check if we win next round")
|
|
|
|
}
|
|
|
|
|
|
|
|
if !win {
|
|
|
|
m.submitNullTicket(base, ticket)
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
b, err := m.createBlock(base, addr, ticket, proof)
|
2019-07-08 23:48:49 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "failed to create block")
|
|
|
|
}
|
2019-10-10 23:41:48 +00:00
|
|
|
log.Infow("mined new block", "cid", b.Cid())
|
2019-07-08 23:48:49 +00:00
|
|
|
|
|
|
|
return b, nil
|
|
|
|
}
|
|
|
|
|
2019-08-15 02:30:21 +00:00
|
|
|
func (m *Miner) submitNullTicket(base *MiningBase, ticket *types.Ticket) {
|
2019-07-08 23:48:49 +00:00
|
|
|
base.tickets = append(base.tickets, ticket)
|
|
|
|
m.lastWork = base
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
func (m *Miner) computeVRF(ctx context.Context, addr address.Address, input []byte) ([]byte, error) {
|
|
|
|
w, err := m.getMinerWorker(ctx, addr, nil)
|
2019-08-15 02:30:21 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2019-08-16 04:40:59 +00:00
|
|
|
return gen.ComputeVRF(ctx, m.api.WalletSign, w, input)
|
2019-08-15 02:30:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Miner) getMinerWorker(ctx context.Context, addr address.Address, ts *types.TipSet) (address.Address, error) {
|
2019-09-06 06:26:02 +00:00
|
|
|
ret, err := m.api.StateCall(ctx, &types.Message{
|
2019-08-15 02:30:21 +00:00
|
|
|
From: addr,
|
|
|
|
To: addr,
|
|
|
|
Method: actors.MAMethods.GetWorkerAddr,
|
|
|
|
}, ts)
|
|
|
|
if err != nil {
|
|
|
|
return address.Undef, xerrors.Errorf("failed to get miner worker addr: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if ret.ExitCode != 0 {
|
|
|
|
return address.Undef, xerrors.Errorf("failed to get miner worker addr (exit code %d)", ret.ExitCode)
|
|
|
|
}
|
|
|
|
|
|
|
|
w, err := address.NewFromBytes(ret.Return)
|
|
|
|
if err != nil {
|
|
|
|
return address.Undef, xerrors.Errorf("GetWorkerAddr returned malformed address: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return w, nil
|
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
func (m *Miner) scratchTicket(ctx context.Context, addr address.Address, base *MiningBase) (*types.Ticket, error) {
|
2019-08-15 02:30:21 +00:00
|
|
|
var lastTicket *types.Ticket
|
|
|
|
if len(base.tickets) > 0 {
|
|
|
|
lastTicket = base.tickets[len(base.tickets)-1]
|
|
|
|
} else {
|
2019-08-16 00:17:09 +00:00
|
|
|
lastTicket = base.ts.MinTicket()
|
2019-08-15 02:30:21 +00:00
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
vrfOut, err := m.computeVRF(ctx, addr, lastTicket.VRFProof)
|
2019-08-15 02:30:21 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &types.Ticket{
|
2019-10-09 04:38:59 +00:00
|
|
|
VRFProof: vrfOut,
|
2019-08-15 02:30:21 +00:00
|
|
|
}, nil
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
|
|
|
|
2019-11-18 21:59:31 +00:00
|
|
|
func (m *Miner) createBlock(base *MiningBase, addr address.Address, ticket *types.Ticket, proof types.ElectionProof) (*types.BlockMsg, error) {
|
2019-07-08 23:48:49 +00:00
|
|
|
|
2019-07-11 02:36:43 +00:00
|
|
|
pending, err := m.api.MpoolPending(context.TODO(), base.ts)
|
2019-07-08 23:48:49 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrapf(err, "failed to get pending messages")
|
|
|
|
}
|
|
|
|
|
2019-09-26 20:47:34 +00:00
|
|
|
msgs, err := selectMessages(context.TODO(), m.api.StateGetActor, base, pending)
|
2019-09-26 03:48:53 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("message filtering failed: %w", err)
|
|
|
|
}
|
2019-07-11 02:36:43 +00:00
|
|
|
|
2019-10-09 04:38:59 +00:00
|
|
|
uts := base.ts.MinTimestamp() + uint64(build.BlockDelay*(len(base.tickets)+1))
|
2019-09-06 17:44:09 +00:00
|
|
|
|
2019-07-08 23:48:49 +00:00
|
|
|
// why even return this? that api call could just submit it for us
|
2019-11-18 21:59:31 +00:00
|
|
|
return m.api.MinerCreateBlock(context.TODO(), addr, base.ts, append(base.tickets, ticket), proof, msgs, uint64(uts))
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|
|
|
|
|
2019-09-26 20:47:34 +00:00
|
|
|
type actorLookup func(context.Context, address.Address, *types.TipSet) (*types.Actor, error)
|
|
|
|
|
|
|
|
func selectMessages(ctx context.Context, al actorLookup, base *MiningBase, msgs []*types.SignedMessage) ([]*types.SignedMessage, error) {
|
2019-09-26 03:48:53 +00:00
|
|
|
out := make([]*types.SignedMessage, 0, len(msgs))
|
2019-09-26 03:53:52 +00:00
|
|
|
inclNonces := make(map[address.Address]uint64)
|
2019-09-26 20:47:34 +00:00
|
|
|
inclBalances := make(map[address.Address]types.BigInt)
|
2019-09-26 03:48:53 +00:00
|
|
|
for _, msg := range msgs {
|
2019-10-14 03:28:19 +00:00
|
|
|
if msg.Message.To == address.Undef {
|
|
|
|
log.Warnf("message in mempool had bad 'To' address")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2019-09-26 03:53:52 +00:00
|
|
|
from := msg.Message.From
|
2019-09-26 20:47:34 +00:00
|
|
|
act, err := al(ctx, from, base.ts)
|
2019-09-26 03:48:53 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, xerrors.Errorf("failed to check message sender balance: %w", err)
|
|
|
|
}
|
|
|
|
|
2019-09-26 03:53:52 +00:00
|
|
|
if _, ok := inclNonces[from]; !ok {
|
|
|
|
inclNonces[from] = act.Nonce
|
2019-09-26 20:47:34 +00:00
|
|
|
inclBalances[from] = act.Balance
|
2019-09-26 03:53:52 +00:00
|
|
|
}
|
|
|
|
|
2019-09-26 20:47:34 +00:00
|
|
|
if inclBalances[from].LessThan(msg.Message.RequiredFunds()) {
|
|
|
|
log.Warnf("message in mempool does not have enough funds: %s", msg.Cid())
|
2019-09-26 03:48:53 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2019-09-26 03:53:52 +00:00
|
|
|
if msg.Message.Nonce > inclNonces[from] {
|
2019-09-26 20:47:34 +00:00
|
|
|
log.Warnf("message in mempool has too high of a nonce (%d > %d) %s", msg.Message.Nonce, inclNonces[from], msg.Cid())
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if msg.Message.Nonce < inclNonces[from] {
|
|
|
|
log.Warnf("message in mempool has already used nonce (%d < %d) %s", msg.Message.Nonce, inclNonces[from], msg.Cid())
|
2019-09-26 03:53:52 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2019-09-26 20:47:34 +00:00
|
|
|
inclNonces[from] = msg.Message.Nonce + 1
|
|
|
|
inclBalances[from] = types.BigSub(inclBalances[from], msg.Message.RequiredFunds())
|
2019-09-26 03:53:52 +00:00
|
|
|
|
2019-09-26 03:48:53 +00:00
|
|
|
out = append(out, msg)
|
|
|
|
}
|
|
|
|
return out, nil
|
2019-07-08 23:48:49 +00:00
|
|
|
}
|