mirror of
https://github.com/kaspanet/kaspad.git
synced 2025-03-30 15:08:33 +00:00
[NOD-191] Added .acceptingBlock and .confirmations methods to blockNode (#305)
* [NOD-191] Added selectedPathChainSlice to virtualBlock. * [NOD-191] Implemented acceptingBlock(). * [NOD-191] Implemented confirmations(). * [NOD-191] Added selectedPathChainSlice tests to TestSelectedPath. * [NOD-191] Fixed a bug in acceptingBlock(). Written tests for confirmations(). * [NOD-191] Written tests for acceptingBlock(). * [NOD-191] Added test to make sure that acceptingBlock(tip) returns the virtual block. * [NOD-191] Added a panic if we somehow feed a childless block that isn't the virtual to acceptingBlock. * [NOD-191] Fixed comments. * [NOD-191] Fixed a bug in acceptingBlock. Added red block tests for acceptingBlock. * [NOD-191] Added red block tests for confirmations. * [NOD-191] Fixed misleading comment and error message.
This commit is contained in:
parent
ec10346e79
commit
da7c9c7dfb
@ -1182,6 +1182,84 @@ func (dag *BlockDAG) GetUTXOEntry(outPoint wire.OutPoint) (*UTXOEntry, bool) {
|
||||
return dag.virtual.utxoSet.get(outPoint)
|
||||
}
|
||||
|
||||
// confirmations returns the current confirmations number of the given node
|
||||
// The confirmations number is defined as follows:
|
||||
// * If the node is red -> 0
|
||||
// * Otherwise -> virtual.blueScore - acceptingBlock.blueScore + 1
|
||||
func (dag *BlockDAG) confirmations(node *blockNode) (uint64, error) {
|
||||
acceptingBlock, err := dag.acceptingBlock(node)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// if acceptingBlock is nil, the node is red
|
||||
if acceptingBlock == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return dag.virtual.blueScore - acceptingBlock.blueScore + 1, nil
|
||||
}
|
||||
|
||||
// acceptingBlock finds the node in the selected-parent chain that had accepted
|
||||
// the given node
|
||||
func (dag *BlockDAG) acceptingBlock(node *blockNode) (*blockNode, error) {
|
||||
// Explicitly handle the DAG tips
|
||||
if dag.virtual.tips().contains(node) {
|
||||
// Return the virtual block if the node is one of the DAG blues
|
||||
for _, tip := range dag.virtual.blues {
|
||||
if tip == node {
|
||||
return &dag.virtual.blockNode, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, this tip is red and doesn't have an accepting block
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return an error if the node is the virtual block
|
||||
if len(node.children) == 0 {
|
||||
if node == &dag.virtual.blockNode {
|
||||
return nil, errors.New("cannot get acceptingBlock for virtual")
|
||||
}
|
||||
// A childless block that isn't a tip or the virtual can never happen. Panicking
|
||||
panic(fmt.Errorf("got childless block %s that is neither a tip nor the virtual", node.hash))
|
||||
}
|
||||
|
||||
// If the node is a chain-block itself, the accepting block is its chain-child
|
||||
if dag.IsInSelectedPathChain(node.hash) {
|
||||
for _, child := range node.children {
|
||||
if dag.IsInSelectedPathChain(child.hash) {
|
||||
return child, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("chain block %s does not have a chain child", node.hash)
|
||||
}
|
||||
|
||||
// Find the only chain block that may contain the node in its blues
|
||||
candidateAcceptingBlock := dag.oldestChainBlockWithBlueScoreGreaterThan(node.blueScore)
|
||||
|
||||
// candidateAcceptingBlock is the accepting block only if it actually contains
|
||||
// the node in its blues
|
||||
for _, blue := range candidateAcceptingBlock.blues {
|
||||
if blue == node {
|
||||
return candidateAcceptingBlock, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, the node is red and doesn't have an accepting block
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// oldestChainBlockWithBlueScoreGreaterThan finds the oldest chain block with a blue score
|
||||
// greater than blueScore
|
||||
func (dag *BlockDAG) oldestChainBlockWithBlueScoreGreaterThan(blueScore uint64) *blockNode {
|
||||
chainBlockIndex := sort.Search(len(dag.virtual.selectedPathChainSlice), func(i int) bool {
|
||||
selectedPathNode := dag.virtual.selectedPathChainSlice[i]
|
||||
return selectedPathNode.blueScore > blueScore
|
||||
})
|
||||
return dag.virtual.selectedPathChainSlice[chainBlockIndex]
|
||||
}
|
||||
|
||||
// IsInSelectedPathChain returns whether or not a block hash is found in the selected path
|
||||
func (dag *BlockDAG) IsInSelectedPathChain(blockHash *daghash.Hash) bool {
|
||||
return dag.virtual.selectedPathChainSet.containsHash(blockHash)
|
||||
|
@ -1248,6 +1248,243 @@ func TestValidateFeeTransaction(t *testing.T) {
|
||||
buildBlock("block5WithRedBlockFees", block5ParentHashes, block5Txs, ErrBadFeeTransaction)
|
||||
}
|
||||
|
||||
func TestConfirmations(t *testing.T) {
|
||||
// Create a new database and DAG instance to run tests against.
|
||||
dag, teardownFunc, err := DAGSetup("TestBlockCount", Config{
|
||||
DAGParams: &dagconfig.SimNetParams,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to setup DAG instance: %v", err)
|
||||
}
|
||||
defer teardownFunc()
|
||||
dag.TestSetBlockRewardMaturity(1)
|
||||
|
||||
// Check that the genesis block of a DAG with only the genesis block in it has confirmations = 1.
|
||||
genesisConfirmations, err := dag.confirmations(dag.genesis)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConfirmations: confirmations for genesis block unexpectedly failed: %s", err)
|
||||
}
|
||||
if genesisConfirmations != 1 {
|
||||
t.Fatalf("TestConfirmations: unexpected confirmations for genesis block. Want: 1, got: %d", genesisConfirmations)
|
||||
}
|
||||
|
||||
processBlocks := func(blocks []*util.Block) {
|
||||
for _, block := range blocks {
|
||||
isOrphan, err := dag.ProcessBlock(block, BFNone)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessBlock fail on block %s: %v\n", block.Hash(), err)
|
||||
}
|
||||
if isOrphan {
|
||||
t.Fatalf("ProcessBlock incorrectly returned block %s is an orphan\n", block.Hash())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a chain of blocks
|
||||
loadedBlocks, err := loadBlocks("blk_0_to_4.dat")
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading file: %v\n", err)
|
||||
}
|
||||
chainBlocks := loadedBlocks[1:]
|
||||
processBlocks(chainBlocks)
|
||||
|
||||
// Make sure that each one of the chain blocks has the expected confirmations number
|
||||
for i, block := range chainBlocks {
|
||||
node := dag.index.LookupNode(block.Hash())
|
||||
confirmations, err := dag.confirmations(node)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConfirmations: confirmations for node 1 unexpectedly failed: %s", err)
|
||||
}
|
||||
|
||||
expectedConfirmations := uint64(len(chainBlocks) - i)
|
||||
if confirmations != expectedConfirmations {
|
||||
t.Fatalf("TestConfirmations: unexpected confirmations for node 1. "+
|
||||
"Want: %d, got: %d", expectedConfirmations, confirmations)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a branching block
|
||||
loadedBlocks, err = loadBlocks("blk_3B.dat")
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading file: %v\n", err)
|
||||
}
|
||||
processBlocks(loadedBlocks)
|
||||
|
||||
// Check that the genesis has a confirmations number == blockCount
|
||||
genesisConfirmations, err = dag.confirmations(dag.genesis)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConfirmations: confirmations for genesis block unexpectedly failed: %s", err)
|
||||
}
|
||||
expectedGenesisConfirmations := dag.blockCount
|
||||
if genesisConfirmations != expectedGenesisConfirmations {
|
||||
t.Fatalf("TestConfirmations: unexpected confirmations for genesis block. "+
|
||||
"Want: %d, got: %d", expectedGenesisConfirmations, genesisConfirmations)
|
||||
}
|
||||
|
||||
// Check that each of the tips had a confirmation number of 1.
|
||||
tips := dag.virtual.tips()
|
||||
for _, tip := range tips {
|
||||
tipConfirmations, err := dag.confirmations(tip)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConfirmations: confirmations for tip unexpectedly failed: %s", err)
|
||||
}
|
||||
if tipConfirmations != 1 {
|
||||
t.Fatalf("TestConfirmations: unexpected confirmations for tip. "+
|
||||
"Want: 1, got: %d", tipConfirmations)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate K blocks to force the "main" chain to become red
|
||||
nodeGenerator := buildNodeGenerator(dag.dagParams.K, false)
|
||||
branchingChainTip := dag.index.LookupNode(loadedBlocks[0].Hash())
|
||||
for i := uint32(0); i < dag.dagParams.K; i++ {
|
||||
nextBranchingChainTip := nodeGenerator(setFromSlice(branchingChainTip))
|
||||
dag.virtual.AddTip(nextBranchingChainTip)
|
||||
branchingChainTip = nextBranchingChainTip
|
||||
}
|
||||
|
||||
// Make sure that a red block has confirmation number = 0
|
||||
redChainBlock := dag.index.LookupNode(chainBlocks[3].Hash())
|
||||
redChainBlockConfirmations, err := dag.confirmations(redChainBlock)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConfirmations: confirmations for red chain block unexpectedly failed: %s", err)
|
||||
}
|
||||
if redChainBlockConfirmations != 0 {
|
||||
t.Fatalf("TestConfirmations: unexpected confirmations for red chain block. "+
|
||||
"Want: 0, got: %d", redChainBlockConfirmations)
|
||||
}
|
||||
|
||||
// Make sure that the red tip has confirmation number = 0
|
||||
redChainTip := dag.index.LookupNode(chainBlocks[len(chainBlocks)-1].Hash())
|
||||
redChainTipConfirmations, err := dag.confirmations(redChainTip)
|
||||
if err != nil {
|
||||
t.Fatalf("TestConfirmations: confirmations for red chain tip unexpectedly failed: %s", err)
|
||||
}
|
||||
if redChainTipConfirmations != 0 {
|
||||
t.Fatalf("TestConfirmations: unexpected confirmations for red tip block. "+
|
||||
"Want: 0, got: %d", redChainTipConfirmations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptingBlock(t *testing.T) {
|
||||
// Create a new database and DAG instance to run tests against.
|
||||
dag, teardownFunc, err := DAGSetup("TestAcceptingBlock", Config{
|
||||
DAGParams: &dagconfig.SimNetParams,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to setup DAG instance: %v", err)
|
||||
}
|
||||
defer teardownFunc()
|
||||
dag.TestSetBlockRewardMaturity(1)
|
||||
|
||||
// Check that the genesis block of a DAG with only the genesis block in it is accepted by the virtual.
|
||||
genesisAcceptingBlock, err := dag.acceptingBlock(dag.genesis)
|
||||
if err != nil {
|
||||
t.Fatalf("TestAcceptingBlock: acceptingBlock for genesis block unexpectedly failed: %s", err)
|
||||
}
|
||||
if genesisAcceptingBlock != &dag.virtual.blockNode {
|
||||
t.Fatalf("TestAcceptingBlock: unexpected acceptingBlock for genesis block. "+
|
||||
"Want: virtual, got: %s", genesisAcceptingBlock.hash)
|
||||
}
|
||||
|
||||
processBlocks := func(blocks []*util.Block) {
|
||||
for _, block := range blocks {
|
||||
isOrphan, err := dag.ProcessBlock(block, BFNone)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessBlock fail on block %s: %v\n", block.Hash(), err)
|
||||
}
|
||||
if isOrphan {
|
||||
t.Fatalf("ProcessBlock incorrectly returned block %s is an orphan\n", block.Hash())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a chain of blocks
|
||||
chainBlocks, err := loadBlocks("blk_0_to_4.dat")
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading file: %v\n", err)
|
||||
}
|
||||
processBlocks(chainBlocks[1:])
|
||||
|
||||
// Make sure that each chain block (including the genesis) is accepted by its child
|
||||
for i, chainBlock := range chainBlocks[:1] {
|
||||
expectedAcceptingBlock := chainBlocks[i+1]
|
||||
expectedAcceptingBlockNode := dag.index.LookupNode(expectedAcceptingBlock.Hash())
|
||||
|
||||
chainBlockNode := dag.index.LookupNode(chainBlock.Hash())
|
||||
chainAcceptingBlockNode, err := dag.acceptingBlock(chainBlockNode)
|
||||
if err != nil {
|
||||
t.Fatalf("TestAcceptingBlock: acceptingBlock for chain block unexpectedly failed: %s", err)
|
||||
}
|
||||
if expectedAcceptingBlockNode != chainAcceptingBlockNode {
|
||||
t.Fatalf("TestAcceptingBlock: unexpected acceptingBlock for chain block. "+
|
||||
"Want: %s, got: %s", expectedAcceptingBlockNode.hash, chainAcceptingBlockNode.hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a branching block
|
||||
branchingBlock, err := loadBlocks("blk_3B.dat")
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading file: %v\n", err)
|
||||
}
|
||||
processBlocks(branchingBlock)
|
||||
|
||||
// Make sure that the accepting block of the parent of the branching block didn't change
|
||||
expectedAcceptingBlock := dag.index.LookupNode(chainBlocks[3].Hash())
|
||||
intersectionBlock := dag.index.LookupNode(chainBlocks[2].Hash())
|
||||
intersectionAcceptingBlock, err := dag.acceptingBlock(intersectionBlock)
|
||||
if err != nil {
|
||||
t.Fatalf("TestAcceptingBlock: acceptingBlock for intersection block unexpectedly failed: %s", err)
|
||||
}
|
||||
if expectedAcceptingBlock != intersectionAcceptingBlock {
|
||||
t.Fatalf("TestAcceptingBlock: unexpected acceptingBlock for intersection block. "+
|
||||
"Want: %s, got: %s", expectedAcceptingBlock.hash, intersectionAcceptingBlock.hash)
|
||||
}
|
||||
|
||||
// Make sure that the accepting block of all the tips in the virtual block
|
||||
for _, tip := range dag.virtual.tips() {
|
||||
tipAcceptingBlock, err := dag.acceptingBlock(tip)
|
||||
if err != nil {
|
||||
t.Fatalf("TestAcceptingBlock: acceptingBlock for tip unexpectedly failed: %s", err)
|
||||
}
|
||||
if tipAcceptingBlock != &dag.virtual.blockNode {
|
||||
t.Fatalf("TestAcceptingBlock: unexpected acceptingBlock for tip. "+
|
||||
"Want: Virtual, got: %s", tipAcceptingBlock.hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate K blocks to force the "main" chain to become red
|
||||
nodeGenerator := buildNodeGenerator(dag.dagParams.K, false)
|
||||
branchingChainTip := dag.index.LookupNode(branchingBlock[0].Hash())
|
||||
for i := uint32(0); i < dag.dagParams.K; i++ {
|
||||
nextBranchingChainTip := nodeGenerator(setFromSlice(branchingChainTip))
|
||||
dag.virtual.AddTip(nextBranchingChainTip)
|
||||
branchingChainTip = nextBranchingChainTip
|
||||
}
|
||||
|
||||
// Make sure that a red block returns nil
|
||||
redChainBlock := dag.index.LookupNode(chainBlocks[3].Hash())
|
||||
redChainBlockAcceptionBlock, err := dag.acceptingBlock(redChainBlock)
|
||||
if err != nil {
|
||||
t.Fatalf("TestAcceptingBlock: acceptingBlock for red chain block unexpectedly failed: %s", err)
|
||||
}
|
||||
if redChainBlockAcceptionBlock != nil {
|
||||
t.Fatalf("TestAcceptingBlock: unexpected acceptingBlock for red chain block. "+
|
||||
"Want: nil, got: %s", redChainBlockAcceptionBlock.hash)
|
||||
}
|
||||
|
||||
// Make sure that a red tip returns nil
|
||||
redChainTip := dag.index.LookupNode(chainBlocks[len(chainBlocks)-1].Hash())
|
||||
redChainTipAcceptingBlock, err := dag.acceptingBlock(redChainTip)
|
||||
if err != nil {
|
||||
t.Fatalf("TestAcceptingBlock: acceptingBlock for red chain tip unexpectedly failed: %s", err)
|
||||
}
|
||||
if redChainTipAcceptingBlock != nil {
|
||||
t.Fatalf("TestAcceptingBlock: unexpected acceptingBlock for red tip block. "+
|
||||
"Want: nil, got: %s", redChainTipAcceptingBlock.hash)
|
||||
}
|
||||
}
|
||||
|
||||
// payToPubKeyHashScript creates a new script to pay a transaction
|
||||
// output to a 20-byte pubkey hash. It is expected that the input is a valid
|
||||
// hash.
|
||||
|
@ -14,8 +14,15 @@ type virtualBlock struct {
|
||||
phantomK uint32
|
||||
utxoSet *FullUTXOSet
|
||||
blockNode
|
||||
// selectedPathChainSet is a block set that includes all the blocks that belong to the chain of selected parents from the virtual block.
|
||||
|
||||
// selectedPathChainSet is a block set that includes all the blocks
|
||||
// that belong to the chain of selected parents from the virtual block.
|
||||
selectedPathChainSet blockSet
|
||||
|
||||
// selectedPathChainSlice is an ordered slice that includes all the
|
||||
// blocks that belong the the chain of selected parents from the
|
||||
// virtual block.
|
||||
selectedPathChainSlice []*blockNode
|
||||
}
|
||||
|
||||
// newVirtualBlock creates and returns a new VirtualBlock.
|
||||
@ -25,6 +32,7 @@ func newVirtualBlock(tips blockSet, phantomK uint32) *virtualBlock {
|
||||
virtual.phantomK = phantomK
|
||||
virtual.utxoSet = NewFullUTXOSet()
|
||||
virtual.selectedPathChainSet = newSet()
|
||||
virtual.selectedPathChainSlice = nil
|
||||
virtual.setTips(tips)
|
||||
|
||||
return &virtual
|
||||
@ -61,11 +69,12 @@ func (v *virtualBlock) setTips(tips blockSet) {
|
||||
// and aren't selected ancestors of the old one.
|
||||
func (v *virtualBlock) updateSelectedPathSet(oldSelectedParent *blockNode) {
|
||||
var intersectionNode *blockNode
|
||||
nodesToAdd := make([]*blockNode, 0)
|
||||
for node := v.blockNode.selectedParent; intersectionNode == nil && node != nil; node = node.selectedParent {
|
||||
if v.selectedPathChainSet.contains(node) {
|
||||
intersectionNode = node
|
||||
} else {
|
||||
v.selectedPathChainSet.add(node)
|
||||
nodesToAdd = append(nodesToAdd, node)
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,11 +82,26 @@ func (v *virtualBlock) updateSelectedPathSet(oldSelectedParent *blockNode) {
|
||||
panic("updateSelectedPathSet: Cannot find intersection node. The block index may be corrupted.")
|
||||
}
|
||||
|
||||
// Remove the nodes in the set from the oldSelectedParent down to the intersectionNode
|
||||
removeCount := 0
|
||||
if intersectionNode != nil {
|
||||
for node := oldSelectedParent; !node.hash.IsEqual(intersectionNode.hash); node = node.selectedParent {
|
||||
v.selectedPathChainSet.remove(node)
|
||||
removeCount++
|
||||
}
|
||||
}
|
||||
// Remove the last removeCount nodes from the slice
|
||||
v.selectedPathChainSlice = v.selectedPathChainSlice[:len(v.selectedPathChainSlice)-removeCount]
|
||||
|
||||
// Reverse nodesToAdd, since we collected them in reverse order
|
||||
for left, right := 0, len(nodesToAdd)-1; left < right; left, right = left+1, right-1 {
|
||||
nodesToAdd[left], nodesToAdd[right] = nodesToAdd[right], nodesToAdd[left]
|
||||
}
|
||||
// Add the nodes to the set and to the slice
|
||||
for _, node := range nodesToAdd {
|
||||
v.selectedPathChainSet.add(node)
|
||||
}
|
||||
v.selectedPathChainSlice = append(v.selectedPathChainSlice, nodesToAdd...)
|
||||
}
|
||||
|
||||
// SetTips replaces the tips of the virtual block with the blocks in the
|
||||
|
@ -126,6 +126,12 @@ func TestSelectedPath(t *testing.T) {
|
||||
if !reflect.DeepEqual(virtual.selectedPathChainSet, firstPath) {
|
||||
t.Fatalf("TestSelectedPath: selectedPathSet doesn't include the expected values. got %v, want %v", virtual.selectedParent, firstPath)
|
||||
}
|
||||
// We expect that selectedPathChainSlice should have all the blocks we've added so far
|
||||
wantLen := 11
|
||||
gotLen := len(virtual.selectedPathChainSlice)
|
||||
if wantLen != gotLen {
|
||||
t.Fatalf("TestSelectedPath: selectedPathChainSlice doesn't have the expected length. got %d, want %d", gotLen, wantLen)
|
||||
}
|
||||
|
||||
secondPath := initialPath.clone()
|
||||
tip = initialTip
|
||||
@ -138,6 +144,13 @@ func TestSelectedPath(t *testing.T) {
|
||||
if !reflect.DeepEqual(virtual.selectedPathChainSet, secondPath) {
|
||||
t.Fatalf("TestSelectedPath: selectedPathSet didn't handle the re-org as expected. got %v, want %v", virtual.selectedParent, firstPath)
|
||||
}
|
||||
// We expect that selectedPathChainSlice should have all the blocks we've added so far except the old chain
|
||||
wantLen = 106
|
||||
gotLen = len(virtual.selectedPathChainSlice)
|
||||
if wantLen != gotLen {
|
||||
t.Fatalf("TestSelectedPath: selectedPathChainSlice doesn't have"+
|
||||
"the expected length, possibly because it didn't handle the re-org as expected. got %d, want %d", gotLen, wantLen)
|
||||
}
|
||||
|
||||
tip = initialTip
|
||||
for i := 0; i < 3; i++ {
|
||||
@ -148,6 +161,13 @@ func TestSelectedPath(t *testing.T) {
|
||||
if !reflect.DeepEqual(virtual.selectedPathChainSet, secondPath) {
|
||||
t.Fatalf("TestSelectedPath: selectedPathSet did an unexpected re-org. got %v, want %v", virtual.selectedParent, firstPath)
|
||||
}
|
||||
// We expect that selectedPathChainSlice not to change
|
||||
wantLen = 106
|
||||
gotLen = len(virtual.selectedPathChainSlice)
|
||||
if wantLen != gotLen {
|
||||
t.Fatalf("TestSelectedPath: selectedPathChainSlice doesn't"+
|
||||
"have the expected length, possibly due to unexpected did an unexpected re-org. got %d, want %d", gotLen, wantLen)
|
||||
}
|
||||
|
||||
// We call updateSelectedPathSet manually without updating the tips, to check if it panics
|
||||
virtual2 := newVirtualBlock(nil, phantomK)
|
||||
|
Loading…
x
Reference in New Issue
Block a user