This commit is contained in:
Anthony Towns 2024-04-29 04:29:35 +02:00 committed by GitHub
commit 0f4c7e09a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 174 additions and 134 deletions

View File

@ -21,7 +21,7 @@ sys.path.insert(0, PATH_BASE_TEST_FUNCTIONAL)
from test_framework.blocktools import get_witness_script, script_BIP34_coinbase_height # noqa: E402
from test_framework.messages import CBlock, CBlockHeader, COutPoint, CTransaction, CTxIn, CTxInWitness, CTxOut, from_binary, from_hex, ser_string, ser_uint256, tx_from_hex # noqa: E402
from test_framework.psbt import PSBT, PSBTMap, PSBT_GLOBAL_UNSIGNED_TX, PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE # noqa: E402
from test_framework.script import CScriptOp # noqa: E402
from test_framework.script import CScript, CScriptOp # noqa: E402
logging.basicConfig(
format='%(asctime)s %(levelname)s %(message)s',
@ -32,12 +32,6 @@ SIGNET_HEADER = b"\xec\xc7\xda\xa2"
PSBT_SIGNET_BLOCK = b"\xfc\x06signetb" # proprietary PSBT global field holding the block being signed
RE_MULTIMINER = re.compile(r"^(\d+)(-(\d+))?/(\d+)$")
def create_coinbase(height, value, spk):
cb = CTransaction()
cb.vin = [CTxIn(COutPoint(0, 0xffffffff), script_BIP34_coinbase_height(height), 0xffffffff)]
cb.vout = [CTxOut(value, spk)]
return cb
def signet_txs(block, challenge):
# assumes signet solution has not been added yet so does not need
# to be removed
@ -72,18 +66,7 @@ def signet_txs(block, challenge):
return spend, to_spend
def do_createpsbt(block, signme, spendme):
psbt = PSBT()
psbt.g = PSBTMap( {PSBT_GLOBAL_UNSIGNED_TX: signme.serialize(),
PSBT_SIGNET_BLOCK: block.serialize()
} )
psbt.i = [ PSBTMap( {PSBT_IN_NON_WITNESS_UTXO: spendme.serialize(),
PSBT_IN_SIGHASH_TYPE: bytes([1,0,0,0])})
]
psbt.o = [ PSBTMap() ]
return psbt.to_base64()
def do_decode_psbt(b64psbt):
def decode_psbt(b64psbt):
psbt = PSBT.from_base64(b64psbt)
assert len(psbt.tx.vin) == 1
@ -110,11 +93,17 @@ def finish_block(block, signet_solution, grind_cmd):
block.rehash()
return block
def generate_psbt(tmpl, reward_spk, *, blocktime=None):
def generate_psbt(tmpl, reward_spk, *, blocktime=None, poolid=None):
signet_spk = tmpl["signet_challenge"]
signet_spk_bin = bytes.fromhex(signet_spk)
cbtx = create_coinbase(height=tmpl["height"], value=tmpl["coinbasevalue"], spk=reward_spk)
scriptSig = script_BIP34_coinbase_height(tmpl["height"])
if poolid is not None:
scriptSig = CScript(b"" + scriptSig + CScriptOp.encode_op_pushdata(poolid))
cbtx = CTransaction()
cbtx.vin = [CTxIn(COutPoint(0, 0xffffffff), scriptSig, 0xffffffff)]
cbtx.vout = [CTxOut(tmpl["coinbasevalue"], reward_spk)]
cbtx.vin[0].nSequence = 2**32-2
cbtx.rehash()
@ -137,29 +126,26 @@ def generate_psbt(tmpl, reward_spk, *, blocktime=None):
signme, spendme = signet_txs(block, signet_spk_bin)
return do_createpsbt(block, signme, spendme)
psbt = PSBT()
psbt.g = PSBTMap( {PSBT_GLOBAL_UNSIGNED_TX: signme.serialize(),
PSBT_SIGNET_BLOCK: block.serialize()
} )
psbt.i = [ PSBTMap( {PSBT_IN_NON_WITNESS_UTXO: spendme.serialize(),
PSBT_IN_SIGHASH_TYPE: bytes([1,0,0,0])})
]
psbt.o = [ PSBTMap() ]
return psbt.to_base64()
def get_reward_address(args, height):
if args.address is not None:
return args.address
if '*' not in args.descriptor:
addr = json.loads(args.bcli("deriveaddresses", args.descriptor))[0]
args.address = addr
return addr
remove = [k for k in args.derived_addresses.keys() if k+20 <= height]
for k in remove:
del args.derived_addresses[k]
addr = args.derived_addresses.get(height, None)
if addr is None:
addrs = json.loads(args.bcli("deriveaddresses", args.descriptor, "[%d,%d]" % (height, height+20)))
addr = addrs[0]
for k, a in enumerate(addrs):
args.derived_addresses[height+k] = a
return addr
def get_poolid(args):
if args.poolid is not None:
if args.poolnum is not None:
logging.error("Can only specify one of --poolid and --poolnum")
raise Exception("bad arguments")
return args.poolid.encode('utf8')
elif args.poolnum is not None:
return b"/signet:%d/" % (args.poolnum)
else:
return None
def get_reward_addr_spk(args, height):
assert args.address is not None or args.descriptor is not None
@ -167,7 +153,20 @@ def get_reward_addr_spk(args, height):
if hasattr(args, "reward_spk"):
return args.address, args.reward_spk
reward_addr = get_reward_address(args, height)
if args.address is not None:
reward_addr = args.address
elif '*' not in args.descriptor:
reward_addr = args.address = json.loads(args.bcli("deriveaddresses", args.descriptor))[0]
else:
remove = [k for k in args.derived_addresses.keys() if k+20 <= height]
for k in remove:
del args.derived_addresses[k]
if height not in args.derived_addresses:
addrs = json.loads(args.bcli("deriveaddresses", args.descriptor, "[%d,%d]" % (height, height+20)))
for k, a in enumerate(addrs):
args.derived_addresses[height+k] = a
reward_addr = args.derived_addresses[height]
reward_spk = bytes.fromhex(json.loads(args.bcli("getaddressinfo", reward_addr))["scriptPubKey"])
if args.address is not None:
# will always be the same, so cache
@ -176,13 +175,14 @@ def get_reward_addr_spk(args, height):
return reward_addr, reward_spk
def do_genpsbt(args):
poolid = get_poolid(args)
tmpl = json.load(sys.stdin)
_, reward_spk = get_reward_addr_spk(args, tmpl["height"])
psbt = generate_psbt(tmpl, reward_spk)
psbt = generate_psbt(tmpl, reward_spk, poolid=poolid)
print(psbt)
def do_solvepsbt(args):
block, signet_solution = do_decode_psbt(sys.stdin.read())
block, signet_solution = decode_psbt(sys.stdin.read())
block = finish_block(block, signet_solution, args.grind_cmd)
print(block.serialize().hex())
@ -225,36 +225,115 @@ def seconds_to_hms(s):
out = "-" + out
return out
def next_block_delta(last_nbits, last_hash, ultimate_target, do_poisson, max_interval):
# strategy:
# 1) work out how far off our desired target we are
# 2) cap it to a factor of 4 since that's the best we can do in a single retarget period
# 3) use that to work out the desired average interval in this retarget period
# 4) if doing poisson, use the last hash to pick a uniformly random number in [0,1), and work out a random multiplier to vary the average by
# 5) cap the resulting interval between 1 second and 1 hour to avoid extremes
class Generate:
INTERVAL = 600.0*2016/2015 # 10 minutes, adjusted for the off-by-one bug
current_target = nbits_to_target(last_nbits)
retarget_factor = ultimate_target / current_target
retarget_factor = max(0.25, min(retarget_factor, 4.0))
avg_interval = INTERVAL * retarget_factor
def __init__(self, multiminer=None, ultimate_target=None, poisson=False, max_interval=1800,
standby_delay=0, backup_delay=0, set_block_time=None,
poolid=None):
if multiminer is None:
multiminer = (0, 1, 1)
(self.multi_low, self.multi_high, self.multi_period) = multiminer
self.ultimate_target = ultimate_target
self.poisson = poisson
self.max_interval = max_interval
self.standby_delay = standby_delay
self.backup_delay = backup_delay
self.set_block_time = set_block_time
self.poolid = poolid
if do_poisson:
det_rand = int(last_hash[-8:], 16) * 2**-32
this_interval_variance = -math.log1p(-det_rand)
else:
this_interval_variance = 1
def next_block_delta(self, last_nbits, last_hash):
# strategy:
# 1) work out how far off our desired target we are
# 2) cap it to a factor of 4 since that's the best we can do in a single retarget period
# 3) use that to work out the desired average interval in this retarget period
# 4) if doing poisson, use the last hash to pick a uniformly random number in [0,1), and work out a random multiplier to vary the average by
# 5) cap the resulting interval between 1 second and 1 hour to avoid extremes
this_interval = avg_interval * this_interval_variance
this_interval = max(1, min(this_interval, max_interval))
current_target = nbits_to_target(last_nbits)
retarget_factor = self.ultimate_target / current_target
retarget_factor = max(0.25, min(retarget_factor, 4.0))
return this_interval
avg_interval = self.INTERVAL * retarget_factor
def next_block_is_mine(last_hash, my_blocks):
det_rand = int(last_hash[-16:-8], 16)
return my_blocks[0] <= (det_rand % my_blocks[2]) < my_blocks[1]
if self.poisson:
det_rand = int(last_hash[-8:], 16) * 2**-32
this_interval_variance = -math.log1p(-det_rand)
else:
this_interval_variance = 1
this_interval = avg_interval * this_interval_variance
this_interval = max(1, min(this_interval, self.max_interval))
return this_interval
def next_block_is_mine(self, last_hash):
det_rand = int(last_hash[-16:-8], 16)
return self.multi_low <= (det_rand % self.multi_period) < self.multi_high
def next_block_time(self, now, bestheader, is_first_block):
if self.set_block_time is not None:
logging.debug("Setting start time to %d", self.set_block_time)
self.mine_time = self.set_block_time
self.action_time = now
self.is_mine = True
elif bestheader["height"] == 0:
time_delta = self.INTERVAL * 100 # plenty of time to mine 100 blocks
logging.info("Backdating time for first block to %d minutes ago" % (time_delta/60))
self.mine_time = now - time_delta
self.action_time = now
self.is_mine = True
else:
time_delta = self.next_block_delta(int(bestheader["bits"], 16), bestheader["hash"])
self.mine_time = bestheader["time"] + time_delta
self.is_mine = self.next_block_is_mine(bestheader["hash"])
self.action_time = self.mine_time
if not self.is_mine:
self.action_time += self.backup_delay
if self.standby_delay > 0:
self.action_time += self.standby_delay
elif is_first_block:
# for non-standby, always mine immediately on startup,
# even if the next block shouldn't be ours
self.action_time = now
# don't want fractional times so round down
self.mine_time = int(self.mine_time)
self.action_time = int(self.action_time)
# can't mine a block 2h in the future; 1h55m for some safety
self.action_time = max(self.action_time, self.mine_time - 6900)
def gbt(self, bcli, bestblockhash, now):
tmpl = json.loads(bcli("getblocktemplate", '{"rules":["signet","segwit"]}'))
if tmpl["previousblockhash"] != bestblockhash:
logging.warning("GBT based off unexpected block (%s not %s), retrying", tmpl["previousblockhash"], bci["bestblockhash"])
time.sleep(1)
return None
if tmpl["mintime"] > self.mine_time:
logging.info("Updating block time from %d to %d", self.mine_time, tmpl["mintime"])
self.mine_time = tmpl["mintime"]
if self.mine_time > now:
logging.error("GBT mintime is in the future: %d is %d seconds later than %d", self.mine_time, (self.mine_time-now), now)
return None
return tmpl
def mine(self, bcli, grind_cmd, tmpl, reward_spk):
psbt = generate_psbt(tmpl, reward_spk, blocktime=self.mine_time, poolid=self.poolid)
input_stream = os.linesep.join([psbt, "true", "ALL"]).encode('utf8')
psbt_signed = json.loads(bcli("-stdin", "walletprocesspsbt", input=input_stream))
if not psbt_signed.get("complete",False):
logging.debug("Generated PSBT: %s" % (psbt,))
sys.stderr.write("PSBT signing failed\n")
return None
block, signet_solution = decode_psbt(psbt_signed["psbt"])
return finish_block(block, signet_solution, grind_cmd)
def do_generate(args):
if args.max_blocks is not None:
@ -312,8 +391,13 @@ def do_generate(args):
logging.error("--max-interval must be at least 960 (16 minutes)")
return 1
poolid = get_poolid(args)
ultimate_target = nbits_to_target(int(args.nbits,16))
gen = Generate(multiminer=my_blocks, ultimate_target=ultimate_target, poisson=args.poisson, max_interval=args.max_interval,
standby_delay=args.standby_delay, backup_delay=args.backup_delay, set_block_time=args.set_block_time, poolid=poolid)
mined_blocks = 0
bestheader = {"hash": None}
lastheader = None
@ -328,104 +412,55 @@ def do_generate(args):
if lastheader is None:
lastheader = bestheader["hash"]
elif bestheader["hash"] != lastheader:
next_delta = next_block_delta(int(bestheader["bits"], 16), bestheader["hash"], ultimate_target, args.poisson, args.max_interval)
next_delta = gen.next_block_delta(int(bestheader["bits"], 16), bestheader["hash"])
next_delta += bestheader["time"] - time.time()
next_is_mine = next_block_is_mine(bestheader["hash"], my_blocks)
next_is_mine = gen.next_block_is_mine(bestheader["hash"])
logging.info("Received new block at height %d; next in %s (%s)", bestheader["height"], seconds_to_hms(next_delta), ("mine" if next_is_mine else "backup"))
lastheader = bestheader["hash"]
# when is the next block due to be mined?
now = time.time()
if args.set_block_time is not None:
logging.debug("Setting start time to %d", args.set_block_time)
mine_time = args.set_block_time
action_time = now
is_mine = True
elif bestheader["height"] == 0:
time_delta = next_block_delta(int(bestheader["bits"], 16), bci["bestblockhash"], ultimate_target, args.poisson, args.max_interval)
time_delta *= 100 # 100 blocks
logging.info("Backdating time for first block to %d minutes ago" % (time_delta/60))
mine_time = now - time_delta
action_time = now
is_mine = True
else:
time_delta = next_block_delta(int(bestheader["bits"], 16), bci["bestblockhash"], ultimate_target, args.poisson, args.max_interval)
mine_time = bestheader["time"] + time_delta
is_mine = next_block_is_mine(bci["bestblockhash"], my_blocks)
action_time = mine_time
if not is_mine:
action_time += args.backup_delay
if args.standby_delay > 0:
action_time += args.standby_delay
elif mined_blocks == 0:
# for non-standby, always mine immediately on startup,
# even if the next block shouldn't be ours
action_time = now
# don't want fractional times so round down
mine_time = int(mine_time)
action_time = int(action_time)
# can't mine a block 2h in the future; 1h55m for some safety
action_time = max(action_time, mine_time - 6900)
gen.next_block_time(now, bestheader, (mined_blocks == 0))
# ready to go? otherwise sleep and check for new block
if now < action_time:
sleep_for = min(action_time - now, 60)
if mine_time < now:
if now < gen.action_time:
sleep_for = min(gen.action_time - now, 60)
if gen.mine_time < now:
# someone else might have mined the block,
# so check frequently, so we don't end up late
# mining the next block if it's ours
sleep_for = min(20, sleep_for)
minestr = "mine" if is_mine else "backup"
logging.debug("Sleeping for %s, next block due in %s (%s)" % (seconds_to_hms(sleep_for), seconds_to_hms(mine_time - now), minestr))
minestr = "mine" if gen.is_mine else "backup"
logging.debug("Sleeping for %s, next block due in %s (%s)" % (seconds_to_hms(sleep_for), seconds_to_hms(gen.mine_time - now), minestr))
time.sleep(sleep_for)
continue
# gbt
tmpl = json.loads(args.bcli("getblocktemplate", '{"rules":["signet","segwit"]}'))
if tmpl["previousblockhash"] != bci["bestblockhash"]:
logging.warning("GBT based off unexpected block (%s not %s), retrying", tmpl["previousblockhash"], bci["bestblockhash"])
time.sleep(1)
tmpl = gen.gbt(args.bcli, bci["bestblockhash"], now)
if tmpl is None:
continue
logging.debug("GBT template: %s", tmpl)
if tmpl["mintime"] > mine_time:
logging.info("Updating block time from %d to %d", mine_time, tmpl["mintime"])
mine_time = tmpl["mintime"]
if mine_time > now:
logging.error("GBT mintime is in the future: %d is %d seconds later than %d", mine_time, (mine_time-now), now)
return 1
# address for reward
reward_addr, reward_spk = get_reward_addr_spk(args, tmpl["height"])
# mine block
logging.debug("Mining block delta=%s start=%s mine=%s", seconds_to_hms(mine_time-bestheader["time"]), mine_time, is_mine)
logging.debug("Mining block delta=%s start=%s mine=%s", seconds_to_hms(gen.mine_time-bestheader["time"]), gen.mine_time, gen.is_mine)
mined_blocks += 1
psbt = generate_psbt(tmpl, reward_spk, blocktime=mine_time)
input_stream = os.linesep.join([psbt, "true", "ALL"]).encode('utf8')
psbt_signed = json.loads(args.bcli("-stdin", "walletprocesspsbt", input=input_stream))
if not psbt_signed.get("complete",False):
logging.debug("Generated PSBT: %s" % (psbt,))
sys.stderr.write("PSBT signing failed\n")
block = gen.mine(args.bcli, args.grind_cmd, tmpl, reward_spk)
if block is None:
return 1
block, signet_solution = do_decode_psbt(psbt_signed["psbt"])
block = finish_block(block, signet_solution, args.grind_cmd)
# submit block
r = args.bcli("-stdin", "submitblock", input=block.serialize().hex().encode('utf8'))
# report
bstr = "block" if is_mine else "backup block"
bstr = "block" if gen.is_mine else "backup block"
next_delta = next_block_delta(block.nBits, block.hash, ultimate_target, args.poisson, args.max_interval)
next_delta = gen.next_block_delta(block.nBits, block.hash)
next_delta += block.nTime - time.time()
next_is_mine = next_block_is_mine(block.hash, my_blocks)
next_is_mine = gen.next_block_is_mine(block.hash)
logging.debug("Block hash %s payout to %s", block.hash, reward_addr)
logging.info("Mined %s at height %d; next in %s (%s)", bstr, tmpl["height"], seconds_to_hms(next_delta), ("mine" if next_is_mine else "backup"))
@ -486,6 +521,8 @@ def main():
cmds = parser.add_subparsers(help="sub-commands")
genpsbt = cmds.add_parser("genpsbt", help="Generate a block PSBT for signing")
genpsbt.set_defaults(fn=do_genpsbt)
genpsbt.add_argument("--poolnum", default=None, type=int, help="Identify blocks that you mine")
genpsbt.add_argument("--poolid", default=None, type=str, help="Identify blocks that you mine (eg: /signet:1/)")
solvepsbt = cmds.add_parser("solvepsbt", help="Solve a signed block PSBT")
solvepsbt.set_defaults(fn=do_solvepsbt)
@ -502,6 +539,8 @@ def main():
generate.add_argument("--backup-delay", default=300, type=int, help="Seconds to delay before mining blocks reserved for other miners (default=300)")
generate.add_argument("--standby-delay", default=0, type=int, help="Seconds to delay before mining blocks (default=0)")
generate.add_argument("--max-interval", default=1800, type=int, help="Maximum interblock interval (seconds)")
generate.add_argument("--poolnum", default=None, type=int, help="Identify blocks that you mine")
generate.add_argument("--poolid", default=None, type=str, help="Identify blocks that you mine (eg: /signet:1/)")
calibrate = cmds.add_parser("calibrate", help="Calibrate difficulty")
calibrate.set_defaults(fn=do_calibrate)

View File

@ -57,6 +57,7 @@ class SignetMinerTest(BitcoinTestFramework):
f'--grind-cmd={self.options.bitcoinutil} grind',
'--nbits=1d00ffff',
f'--set-block-time={int(time.time())}',
'--poolnum=99',
], check=True, stderr=subprocess.STDOUT)
assert_equal(node.getblockcount(), 1)