Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Composer V2 #2873

Merged
merged 67 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
00a2fa3
Introduce composer.py
Ouziel Sep 27, 2024
b4f2300
Add unit tests
Ouziel Sep 30, 2024
c6e0438
Merge branch 'develop' into composer
Ouziel Oct 3, 2024
106405c
fix test
Ouziel Oct 3, 2024
32a168d
tweaks
Ouziel Oct 3, 2024
5a8ea0a
Migrate to 'bitcoinutils' which supports taproot addresses
Ouziel Oct 3, 2024
45fa9fc
migrate get vsize
Ouziel Oct 3, 2024
d3a9a70
Merge branch 'develop' into composer
Ouziel Oct 3, 2024
59ab8ab
more unit tests
Ouziel Oct 3, 2024
0289a65
Merge branch 'develop' into composer
Ouziel Dec 19, 2024
d577e98
fix pytest
Ouziel Dec 19, 2024
b9ec24e
Composer v2 progress
Ouziel Dec 23, 2024
9c1b06d
composer v2 progress
Ouziel Dec 23, 2024
3e0d203
Add utxo locks
Ouziel Dec 24, 2024
641bc8a
Add more_outputs params
Ouziel Dec 24, 2024
920a193
Tweaks
Ouziel Dec 24, 2024
9f55b5f
ensure unspent_list is complete
Ouziel Dec 24, 2024
ff04ff7
tweaks
Ouziel Dec 24, 2024
88a6953
Correctly set has_segwit parameter
Ouziel Dec 24, 2024
511e84b
backward compatibilities
Ouziel Dec 24, 2024
c14dd45
tweaks
Ouziel Dec 25, 2024
5ab194a
Merge branch 'develop' into composer
Ouziel Dec 25, 2024
466b77f
fixes
Ouziel Dec 25, 2024
3d3326a
More fixes; Regtest OK
Ouziel Dec 26, 2024
c74f83e
Merge branch 'develop' into composer
Ouziel Dec 26, 2024
23dfc8d
More fixes; property tests OK
Ouziel Dec 26, 2024
53f9500
Cleaning; no need to pass inputs_set and keys
Ouziel Dec 26, 2024
65cb56e
Delete old code; progress in fixing tests
Ouziel Dec 27, 2024
1a1e8dd
Merge branch 'develop' into composer
Ouziel Dec 27, 2024
327f762
Merge branch 'develop' into composer
Ouziel Dec 28, 2024
b654b2c
pytest fixing progress
Ouziel Dec 28, 2024
feda13e
fix typo; pytest fixing progress
Ouziel Dec 28, 2024
b8a0676
progress
Ouziel Dec 28, 2024
1fc79e6
pytest fixing progress
Ouziel Dec 29, 2024
15a44f6
fix existing pytest
Ouziel Dec 29, 2024
64f25d8
Add function to check transaction sanity
Ouziel Dec 29, 2024
15d2cd6
cleaning
Ouziel Dec 29, 2024
c4a120a
More fixes, more tweaks
Ouziel Dec 30, 2024
95cf95e
fixes; Clean construct_params; add warnings
Ouziel Dec 30, 2024
7e07197
restore pubkeys parameters
Ouziel Dec 30, 2024
4d8c3cf
restore and fix test with p2sh source
Ouziel Dec 30, 2024
5c014e7
clean debug
Ouziel Dec 30, 2024
5c84f45
fix get pubkeys
Ouziel Dec 30, 2024
3f7a66e
tweak error messges for invalid utxos
Ouziel Dec 30, 2024
58bb8da
fix typo
Ouziel Dec 31, 2024
586b87a
tweak with balance checking
Ouziel Dec 31, 2024
e2b3745
re-enable and fix COMPOSER_VECTOR; fix regtest; fixes and tweaks
Ouziel Dec 31, 2024
419fe65
tweaks
Ouziel Dec 31, 2024
16d874b
exclude silently utxo with balances when inputs_set is not provided; …
Ouziel Dec 31, 2024
05caf3e
fix missing params and name in compose result
Ouziel Dec 31, 2024
2abe695
Don't retry RPC calls on API requests
Ouziel Dec 31, 2024
44a6c2b
fix pytest
Ouziel Dec 31, 2024
15ff5ac
Merge branch 'develop' into composer
Ouziel Dec 31, 2024
405a506
more tests; more fixes
Ouziel Dec 31, 2024
0d30d8f
More tests
Ouziel Jan 1, 2025
92409e7
more tests
Ouziel Jan 1, 2025
574cb0c
last unit tests for composer.py
Ouziel Jan 2, 2025
2d527ae
update release notes
Ouziel Jan 2, 2025
b3aa413
fix pytest
Ouziel Jan 2, 2025
3f56246
Insert dummy signatures before needed fee calculation
Ouziel Jan 2, 2025
9c3fc90
Calculate fees with adjusted vsize
Ouziel Jan 3, 2025
59348a8
fix tests
Ouziel Jan 3, 2025
d1ed85d
Add utxo_value parameter for attach and move
Ouziel Jan 3, 2025
5854ac3
Tweak docs; more tests; fix regtest
Ouziel Jan 3, 2025
acfbf06
more sigops tests
Ouziel Jan 3, 2025
aa4fca3
fix typo
Ouziel Jan 3, 2025
35d1a46
fix typo
Ouziel Jan 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions counterparty-core/counterpartycore/lib/backend/bitcoind.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ def fee_per_kb(
return int(max(feeperkb["feerate"] * config.UNIT, config.DEFAULT_FEE_PER_KB_ESTIMATE_SMART))


def satoshis_per_vbyte(
conf_target: int = config.ESTIMATE_FEE_CONF_TARGET, mode: str = config.ESTIMATE_FEE_MODE
):
feeperkb = rpc("estimatesmartfee", [conf_target, mode])

if "errors" in feeperkb and feeperkb["errors"][0] == "Insufficient data or no feerate found":
return config.DEFAULT_FEE_PER_KB_ESTIMATE_SMART

return (feeperkb["feerate"] * config.UNIT) / 1024


def get_btc_supply(normalize=False):
f"""returns the total supply of {config.BTC} (based on what Bitcoin Core says the current block height is)""" # noqa: B021
block_count = getblockcount()
Expand Down Expand Up @@ -333,6 +344,10 @@ def get_tx_out_amount(tx_hash, vout):
return raw_tx["vout"][vout]["value"]


def get_utxo_value(tx_hash, vout):
return get_tx_out_amount(tx_hash, vout)


class BlockFetcher:
def __init__(self, first_block) -> None:
self.current_block = first_block
Expand Down
257 changes: 257 additions & 0 deletions counterparty-core/counterpartycore/lib/composer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import binascii

from bitcoinutils.keys import P2pkhAddress, P2wpkhAddress, PublicKey
from bitcoinutils.script import Script, b_to_h
from bitcoinutils.transactions import Transaction, TxInput, TxOutput

from counterpartycore.lib import arc4, backend, config, exceptions, script, transaction, util
from counterpartycore.lib.transaction_helper.common_serializer import make_fully_valid
from counterpartycore.lib.transaction_helper.transaction_outputs import chunks

MAX_INPUTS_SET = 100


def search_pubkey(address, provides_pubkeys=None):
if provides_pubkeys is None:
raise exceptions.ComposeError("no pubkeys provided")
for pubkey in provides_pubkeys:
try:
if not pubkey:
raise exceptions.ComposeError(f"invalid pubkey: {pubkey}")
check_address = PublicKey.from_hex(pubkey).get_address(compressed=True).to_string()
print(check_address)
if check_address == address:
return pubkey
check_address = PublicKey.from_hex(pubkey).get_address(compressed=False).to_string()
print(check_address)
if check_address == address:
return pubkey
except ValueError as e:
raise exceptions.ComposeError(f"invalid pubkey: {pubkey}") from e
raise exceptions.ComposeError(f"`{address}` pubkey not found in provided pubkeys")


def get_script(address, pubkeys=None):
if script.is_multisig(address):
signatures_required, addresses, signatures_possible = script.extract_array(address)
pubkeys = [search_pubkey(address, pubkeys) for address in addresses]
return Script(
[signatures_required] + pubkeys + [signatures_possible] + ["OP_CHECKMULTISIG"]
)
if script.is_bech32(address):
return P2wpkhAddress(address).to_script_pub_key()
return P2pkhAddress(address).to_script_pub_key()


def get_default_value(address):
if script.is_multisig(address):
return config.DEFAULT_MULTISIG_DUST_SIZE
return config.DEFAULT_REGULAR_DUST_SIZE


def perpare_non_data_outputs(destinations, pubkeys=None):
outputs = []
for address, value in destinations:
output_value = value or get_default_value(address)
outputs.append(TxOutput(output_value, get_script(address, pubkeys)))
return outputs


def determine_encoding(data, desired_encoding="auto"):
encoding = desired_encoding
if desired_encoding == "auto":
if len(data) + len(config.PREFIX) <= config.OP_RETURN_MAX_SIZE:
encoding = "opreturn"
else:
encoding = "multisig"
if encoding not in ("multisig", "opreturn"):
Fixed Show fixed Hide fixed
raise exceptions.TransactionError(f"Not supported encoding: {encoding}")
return encoding


def encrypt_data(data, arc4_key):
key = arc4.init_arc4(binascii.unhexlify(arc4_key))
return key.encrypt(data)


def prepare_opreturn_output(data, arc4_key=None):
if len(data) + len(config.PREFIX) > config.OP_RETURN_MAX_SIZE:
raise exceptions.TransactionError("One `OP_RETURN` output per transaction")
opreturn_data = config.PREFIX + data
if arc4_key:
opreturn_data = encrypt_data(opreturn_data, arc4_key)
return [TxOutput(0, Script(["OP_RETURN", b_to_h(opreturn_data)]))]


def data_to_pubkey_pairs(data, arc4_key=None):
# Two pubkeys, minus length byte, minus prefix, minus two nonces,
# minus two sign bytes.
chunk_size = (33 * 2) - 1 - len(config.PREFIX) - 2 - 2
data_array = list(chunks(data, chunk_size))
pubkey_pairs = []
for data_chunk in data_array:
# Get data (fake) public key.
pad_length = (33 * 2) - 1 - 2 - 2 - len(data_chunk)
assert pad_length >= 0
output_data = bytes([len(data_chunk)]) + data_chunk + (pad_length * b"\x00") # noqa: PLW2901
if arc4_key:
output_data = encrypt_data(output_data, arc4_key)
data_pubkey_1 = make_fully_valid(output_data[:31])
data_pubkey_2 = make_fully_valid(output_data[31:])
pubkey_pairs.append((b_to_h(data_pubkey_1), b_to_h(data_pubkey_2)))
return pubkey_pairs


def prepare_multisig_output(data, source, pubkeys, arc4_key=None):
source_pubkey = search_pubkey(source, pubkeys)
pubkey_pairs = data_to_pubkey_pairs(data, arc4_key)
outputs = []
for pubkey_pair in pubkey_pairs:
output_script = Script(
[1, pubkey_pair[0], pubkey_pair[1], source_pubkey, 3, "OP_CHECKMULTISIG"]
)
outputs.append(TxOutput(config.DEFAULT_MULTISIG_DUST_SIZE, output_script))
return outputs


def prepare_data_outputs(encoding, data, source, pubkeys, arc4_key=None):
data_encoding = determine_encoding(data, encoding)
if data_encoding == "multisig":
return prepare_multisig_output(data, source, pubkeys, arc4_key)
if data_encoding == "opreturn":
return prepare_opreturn_output(data, arc4_key)
raise exceptions.TransactionError(f"Not supported encoding: {encoding}")


def prepare_outputs(source, destinations, data, pubkeys, encoding, arc4_key=None):
Fixed Show fixed Hide fixed
outputs = perpare_non_data_outputs(destinations)
if data:
outputs += prepare_data_outputs(encoding, data, source, pubkeys, arc4_key)
return outputs


def prepare_unspent_list(inputs_set: str):
unspent_list = []
utxos_list = inputs_set.split(",")
if len(utxos_list) > MAX_INPUTS_SET:
raise exceptions.ComposeError(
f"too many UTXOs in inputs_set (max. {MAX_INPUTS_SET}): {len(utxos_list)}"
)
for utxo in utxos_list:
if not util.is_utxo_format(utxo):
raise exceptions.ComposeError(f"invalid UTXO: {utxo}")
txid, vout = utxo.split(":")
vout = int(vout)
try:
value = backend.bitcoind.get_utxo_value(txid, vout)
except Exception as e:
raise exceptions.ComposeError(f"invalid UTXO: {utxo}") from e
unspent_list.append(
{
"txid": txid,
"vout": vout,
"value": value,
}
)
return sorted(unspent_list, key=lambda x: x["value"], reverse=True)


def select_utxos(unspent_list, target_amount):
total_amount = 0
selected_utxos = []
for utxo in unspent_list:
total_amount += utxo["value"]
selected_utxos.append(utxo)
if total_amount >= target_amount:
break
if total_amount < target_amount:
raise exceptions.ComposeError(f"Insufficient funds for the target amount: {target_amount}")
return selected_utxos


def utxos_to_txins(utxos: list):
inputs = []
for utxo in utxos:
inputs.append(TxInput(utxo["txid"], utxo["vout"]))
return inputs


def get_needed_fee(tx, satoshis_per_vbyte=None):
virtual_size = tx.get_vsize()
if satoshis_per_vbyte:
return satoshis_per_vbyte * virtual_size
return backend.bitcoind.satoshis_per_vbyte() * virtual_size


def get_minimum_change(source):
if script.is_multisig(source):
return config.MULTISIG_DUST_SIZE
Fixed Show fixed Hide fixed
return config.REGULAR_DUST_SIZE
Fixed Show fixed Hide fixed


def prepare_transaction(source, outputs, pubkeys, unspent_list, desired_fee):
outputs_total = sum(output["value"] for output in outputs)
target_amount = outputs_total + desired_fee
selected_utxos = select_utxos(unspent_list, target_amount)
input_total = sum(input["value"] for input in selected_utxos)
inputs = utxos_to_txins(selected_utxos)
change = input_total - target_amount
change_outputs = []
if change > get_minimum_change(source):
change_outputs.append(TxOutput(change, get_script(source, pubkeys)))
else:
change = 0
return inputs, change_outputs, input_total


def construct_transaction(source, outputs, pubkeys, unspent_list, desired_fee):
inputs, change_outputs, _input_total = prepare_transaction(
source, outputs, pubkeys, unspent_list, desired_fee
)
tx = Transaction(inputs, outputs + change_outputs)
return tx


def get_estimated_fee(source, outputs, pubkeys, unspent_list, satoshis_per_vbyte=None):
# calculate fee for a transaction with desired_fee = 0
tx = construct_transaction(source, outputs, pubkeys, unspent_list, 0)
return get_needed_fee(tx, satoshis_per_vbyte)


def compose_transaction(
Fixed Show fixed Hide fixed
db, name, params, pubkeys, inputs_set, encoding="auto", exact_fee=None, satoshis_per_vbyte=None
):
source, destinations, data = transaction.compose_data(db, name, params)
unspent_list = prepare_unspent_list(inputs_set)

# prepare non obfuscted outputs
clear_outputs = prepare_outputs(source, destinations, data, pubkeys, encoding)

if exact_fee:
desired_fee = exact_fee
else:
# use non obfuscated outputs to calculate estimated fee...
desired_fee = get_estimated_fee(
source, clear_outputs, pubkeys, unspent_list, satoshis_per_vbyte
)

# prepare transaction with desired fee and no-obfuscated outputs
inputs, change_outputs, btc_in = prepare_transaction(
source, clear_outputs, pubkeys, unspent_list, desired_fee
)
# now we have inputs we can prepare obfuscated outputs
outputs = prepare_outputs(
source, destinations, data, pubkeys, encoding, arc4_key=inputs[0]["txid"]
)
tx = Transaction(inputs, outputs + change_outputs)
btc_out = sum(output.nValue for output in outputs)
Fixed Show fixed Hide fixed
btc_change = sum(change_output.nValue for change_output in change_outputs)

return {
"btc_in": btc_in,
"btc_out": btc_out,
"btc_change": btc_change,
"btc_fee": btc_in - btc_out - btc_change,
"unsigned_tx_hex": tx.serialize().hex(),
"data": config.PREFIX + data if data else None,
}
15 changes: 14 additions & 1 deletion counterparty-core/counterpartycore/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,12 @@ def get_utxo_address_and_value(value):
def get_transaction_fee(db, transaction_type, block_index):
return 10

def mocked_get_utxo_value(txid, vout):
return 999

def satoshis_per_vbyte():
return 3

def determine_encoding(
data, desired_encoding="auto", op_return_max_size=config.OP_RETURN_MAX_SIZE
):
Expand Down Expand Up @@ -669,8 +675,15 @@ def determine_encoding(
"counterpartycore.lib.ledger.get_matching_orders", ledger.get_matching_orders_no_cache
)

# monkeypatch.setattr("counterpartycore.lib.gas.get_transaction_fee", get_transaction_fee)
monkeypatch.setattr("counterpartycore.lib.gas.get_transaction_fee", get_transaction_fee)

monkeypatch.setattr(
"counterpartycore.lib.backend.bitcoind.get_utxo_value", mocked_get_utxo_value
)
monkeypatch.setattr("counterpartycore.lib.transaction.determine_encoding", determine_encoding)
monkeypatch.setattr(
"counterpartycore.lib.backend.bitcoind.satoshis_per_vbyte", satoshis_per_vbyte
)

monkeypatch.setattr(
"counterpartycore.lib.ledger.asset_issued_total", ledger.asset_issued_total_no_cache
Expand Down
Loading
Loading