diff --git a/electrum_nmc/electrum/commands.py b/electrum_nmc/electrum/commands.py index 3228302963ff..2b01b5d0fda7 100644 --- a/electrum_nmc/electrum/commands.py +++ b/electrum_nmc/electrum/commands.py @@ -49,7 +49,7 @@ from .network import BestEffortRequestFailed from .verifier import verify_tx_is_in_block from .transaction import (Transaction, multisig_script, TxOutput, PartialTransaction, PartialTxOutput, - tx_from_any, PartialTxInput, TxOutpoint) + tx_from_any, PartialTxInput, TxOutpoint, Sighash, NAMECOIN_VERSION) from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED from .synchronizer import Notifier from .mnemonic import Mnemonic @@ -1055,6 +1055,289 @@ async def name_autoregister(self, identifier, value="", name_encoding='ascii', v await self.broadcast(new_tx, stream_id=stream_id) + @command('wpn') + async def name_buy(self, identifier, offer=None, value=None, name_encoding='ascii', value_encoding='ascii', destination=None, amount=0.0, outputs=[], fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None, nocheck=False, unsigned=False, rbf=None, password=None, locktime=None, wallet: Abstract_Wallet = None): + """Buy an existing name from the current owner.""" + + self.nocheck = nocheck + + name_encoding = Encoding(name_encoding) + value_encoding = Encoding(value_encoding) + + tx_fee = satoshis(fee) + domain_addr = from_addr.split(',') if from_addr else None + domain_coins = from_coins.split(',') if from_coins else None + change_addr = self._resolver(change_addr, wallet) + domain_addr = None if domain_addr is None else map(self._resolver, domain_addr, repeat(wallet)) + + # Allow buying a name without any value changes by omitting the + # value. + if value is None: + show_results = await self.name_show(identifier, name_encoding=name_encoding.value, value_encoding=value_encoding.value, wallet=wallet) + + # TODO: handle semi-expired names + + # This check is in place to prevent an attack where an ElectrumX + # server supplies an unconfirmed name_update transaction with a + # malicious value and then tricks the wallet owner into signing a + # name renewal with that malicious value. expires_in is None when + # the transaction has 0 confirmations. + expires_in = show_results["expires_in"] + if expires_in is None or expires_in > constants.net.NAME_EXPIRATION - 12: + raise NameUpdatedTooRecentlyError("Name was updated too recently to safely determine current value. Either wait or specify an explicit value.") + + value = show_results["value"] + + identifier_bytes = name_from_str(identifier, name_encoding) + validate_identifier_length(identifier_bytes) + value_bytes = name_from_str(value, value_encoding) + validate_value_length(value_bytes) + name_op = {"op": OP_NAME_UPDATE, "name": identifier_bytes, "value": value_bytes} + memo = "Buy: " + format_name_identifier(identifier_bytes) + + if destination is None: + request = await self.add_request(None, memo=memo, wallet=wallet) + destination = request['address'] + + if offer is None and len(outputs) > 0: + raise Exception("Extra outputs not allowed when creating trade offer") + + amount_sat = satoshis(amount) + + if offer is not None: + offer = Transaction(offer) + + # Validate offer + if len(offer.inputs()) != 1: + raise Exception("Offer must have exactly 1 input") + if len(offer.outputs()) != 1: + raise Exception("Offer must have exactly 1 output") + offer_output = offer.outputs()[0] + offer_output_name_op = offer_output.name_op + if offer_output_name_op is not None: + raise Exception("Sell offer output must be currency") + offer_input = offer.inputs()[0] + offer_input_outpoint = offer_input.prevout.to_json() + offer_input_tx = await self.gettransaction(offer_input_outpoint[0]) + offer_input_tx = Transaction(offer_input_tx) + offer_input_output = offer_input_tx.outputs()[offer_input_outpoint[1]] + offer_input_name_op = offer_input_output.name_op + if offer_input_name_op is None: + raise Exception("Sell offer input must be name operation") + if offer_input_name_op["name"] != identifier_bytes: + raise Exception("Sell offer input name identifier mismatch") + offer_amount_sat = offer_output.value_display - offer_input_output.value_display + if offer_amount_sat != amount_sat: + raise Exception("Sell offer price mismatch") + + # Currency output from counterparty + offer_output_partial = PartialTxOutput(scriptpubkey=offer_output.scriptpubkey, value=offer_output.value) + final_outputs = [offer_output_partial] + + # Name input from counterparty + offer_input_partial = PartialTxInput(prevout=offer_input.prevout, nsequence=offer_input.nsequence, is_coinbase_output=offer_input.is_coinbase_output()) + offer_input_partial._trusted_value_sats = offer_input_output.value + offer_input_partial.sighash = Sighash.SINGLE | Sighash.ANYONECANPAY + raw_inputs = [offer_input_partial] + + # Name output from user + destination = self._resolver(destination, wallet) + name_output = PartialTxOutput.from_address_and_value(destination, 0) + name_output.add_name_op(name_op) + final_outputs.append(name_output) + + # Currency input from user will be added by coin selector + + locktime = offer.locktime + + # Temporarily inflate name output so that the fee estimator gets + # the right size (otherwise it doesn't know about the + # scriptSig+witness that we splice in right before we sign the + # transaction). + sig_size = len(offer_input.script_sig) + (0 if offer_input.witness is None else len(offer_input.witness)//4) + orig_name_scriptpubkey = final_outputs[1].scriptpubkey + final_outputs[1].scriptpubkey += sig_size * b'0' + else: + final_outputs = [] + destination = self._resolver(destination, wallet) + name_output = PartialTxOutput.from_address_and_value(destination, amount_sat) + name_output.add_name_op(name_op) + final_outputs.append(name_output) + + raw_inputs = [] + + for o_address, o_amount in outputs: + o_address = self._resolver(o_address, wallet) + amount_sat = satoshis(o_amount) + final_outputs.append(PartialTxOutput.from_address_and_value(o_address, amount_sat)) + + tx = wallet.create_transaction( + final_outputs, + fee=tx_fee, + feerate=feerate, + change_addr=None, + domain_addr=domain_addr, + domain_coins=domain_coins, + unsigned=True, + rbf=rbf, + locktime=locktime, + name_inputs_raw=raw_inputs) + + if offer is not None: + tx._inputs[0].script_sig = offer_input.script_sig + tx._inputs[0].witness = offer_input.witness + if not tx._inputs[0].is_complete(): + raise Exception("Offer signature incomplete") + + # Deflate the name output back to its correct value, since fee estimation is complete + tx._outputs[1].scriptpubkey = orig_name_scriptpubkey + else: + if len(tx.inputs()) > 1: + raise Exception("Wallet selected a currency input that was too small; try freezing small inputs") + + # Store the difference between the input amount and the trade amount as + # change in the name output. + input_sat = tx.inputs()[0].value_sats_display() + change_sat = input_sat - amount_sat + name_output.value_display = change_sat + + # Only have one output (the name output with change); set SIGHASH and + # clear cache. + tx._outputs = [name_output] + tx._inputs[0].sighash = Sighash.SINGLE | Sighash.ANYONECANPAY + tx.invalidate_ser_cache() + + wallet.sign_transaction(tx, password) + return tx.serialize() + + @command('wpn') + async def name_sell(self, identifier, offer=None, name_encoding='ascii', destination=None, amount=0.0, outputs=[], fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None, nocheck=False, unsigned=False, rbf=None, password=None, locktime=None, wallet: Abstract_Wallet = None): + """Sell a name you currently own.""" + + self.nocheck = nocheck + + name_encoding = Encoding(name_encoding) + + tx_fee = satoshis(fee) + domain_addr = from_addr.split(',') if from_addr else None + domain_coins = from_coins.split(',') if from_coins else None + change_addr = self._resolver(change_addr, wallet) + domain_addr = None if domain_addr is None else map(self._resolver, domain_addr, repeat(wallet)) + + identifier_bytes = name_from_str(identifier, name_encoding) + validate_identifier_length(identifier_bytes) + memo = "Sell: " + format_name_identifier(identifier_bytes) + + if destination is None: + request = await self.add_request(None, memo=memo, wallet=wallet) + destination = request['address'] + + if offer is None and len(outputs) > 0: + raise Exception("Extra outputs not allowed when creating trade offer") + + amount_sat = satoshis(amount) + + if offer is not None: + offer = Transaction(offer) + + # Validate offer + if len(offer.inputs()) != 1: + raise Exception("Offer must have exactly 1 input") + if len(offer.outputs()) != 1: + raise Exception("Offer must have exactly 1 output") + offer_output = offer.outputs()[0] + offer_output_name_op = offer_output.name_op + if offer_output_name_op is None: + raise Exception("Buy offer output must be name operation") + offer_input = offer.inputs()[0] + offer_input_outpoint = offer_input.prevout.to_json() + offer_input_tx = await self.gettransaction(offer_input_outpoint[0]) + offer_input_tx = Transaction(offer_input_tx) + offer_input_output = offer_input_tx.outputs()[offer_input_outpoint[1]] + offer_input_name_op = offer_input_output.name_op + if offer_input_name_op is not None: + raise Exception("Buy offer input must be currency") + if offer_output_name_op["name"] != identifier_bytes: + raise Exception("Buy offer input name identifier mismatch") + offer_amount_sat = offer_input_output.value_display - offer_output.value_display + if offer_amount_sat != amount_sat: + raise Exception("Buy offer price mismatch") + + # Name output from counterparty + offer_output_partial = PartialTxOutput(scriptpubkey=offer_output.scriptpubkey, value=offer_output.value) + final_outputs = [offer_output_partial] + + # Currency input from counterparty + offer_input_partial = PartialTxInput(prevout=offer_input.prevout, nsequence=offer_input.nsequence, is_coinbase_output=offer_input.is_coinbase_output()) + offer_input_partial._trusted_value_sats = offer_input_output.value + offer_input_partial.sighash = Sighash.SINGLE | Sighash.ANYONECANPAY + raw_inputs = [offer_input_partial] + + # Name input from user and currency output from user will be added + # by coin selector + + locktime = offer.locktime + + # Temporarily inflate name output so that the fee estimator gets + # the right size (otherwise it doesn't know about the + # scriptSig+witness that we splice in right before we sign the + # transaction). + sig_size = len(offer_input.script_sig) + (0 if offer_input.witness is None else len(offer_input.witness)//4) + orig_name_scriptpubkey = final_outputs[0].scriptpubkey + final_outputs[0].scriptpubkey += sig_size * b'0' + else: + final_outputs = [] + destination = self._resolver(destination, wallet) + currency_output = PartialTxOutput.from_address_and_value(destination, 0) + final_outputs.append(currency_output) + + raw_inputs = [] + + for o_address, o_amount in outputs: + o_address = self._resolver(o_address, wallet) + amount_sat = satoshis(o_amount) + final_outputs.append(PartialTxOutput.from_address_and_value(o_address, amount_sat)) + + tx = wallet.create_transaction( + final_outputs, + fee=tx_fee, + feerate=feerate, + change_addr=None, + domain_addr=domain_addr, + domain_coins=domain_coins, + unsigned=True, + rbf=rbf, + locktime=locktime, + name_input_identifiers=[identifier_bytes], + name_inputs_raw=raw_inputs) + + if offer is not None: + tx._inputs[0].script_sig = offer_input.script_sig + tx._inputs[0].witness = offer_input.witness + if not tx._inputs[0].is_complete(): + raise Exception("Offer signature incomplete") + + # Deflate the name output back to its correct value, since fee estimation is complete + tx._outputs[0].scriptpubkey = orig_name_scriptpubkey + else: + # Store the sum of input amount and trade amount in output; + # counterparty can make change. + input_sat = tx.inputs()[0].value_sats_display() + output_sat = input_sat + amount_sat + currency_output.value_display = output_sat + + # Only have one output (the currency output); set SIGHASH and clear + # cache. Explicitly set the transaction version to enable name + # operations; this won't happen automatically because the only output + # in the offer is a currency output. + tx._outputs = [currency_output] + tx._inputs[0].sighash = Sighash.SINGLE | Sighash.ANYONECANPAY + tx._version = NAMECOIN_VERSION + tx.invalidate_ser_cache() + + wallet.sign_transaction(tx, password) + return tx.serialize() + @command('w') async def onchain_history(self, year=None, show_addresses=False, show_fiat=False, wallet: Abstract_Wallet = None): """Wallet onchain history. Returns the transaction history of your wallet.""" @@ -1931,6 +2214,7 @@ def eval_bool(x: str) -> bool: 'commitment': (None, "Pre-registration commitment (use if you're pre-registering a name for someone else)"), 'salt': (None, "Salt for the name pre-registration commitment (returned by name_new; you can usually omit this)"), 'name_new_txid':(None, "Transaction ID for the name pre-registration (returned by name_new; you can usually omit this)"), + 'offer': (None, "Existing name trade offer to accept"), 'trigger_txid':(None, "Broadcast the transaction when this txid reaches the specified number of confirmations"), 'trigger_name':(None, "Broadcast the transaction when this name reaches the specified number of confirmations"), 'options': (None, "Options in Namecoin-Core-style dict"), diff --git a/electrum_nmc/electrum/names.py b/electrum_nmc/electrum/names.py index 663446069079..b1494063dbfb 100644 --- a/electrum_nmc/electrum/names.py +++ b/electrum_nmc/electrum/names.py @@ -370,11 +370,17 @@ def get_default_name_tx_label(wallet, tx) -> Optional[str]: if not name_input_is_mine and not name_output_is_mine: return None if name_input_is_mine and not name_output_is_mine: + is_relevant, is_mine, amount, fee = wallet.get_wallet_delta(tx) + if amount > 0: + return "Sale: " + format_name_identifier(name_op["name"]) return "Transfer (Outgoing): " + format_name_identifier(name_op["name"]) if not name_input_is_mine and name_output_is_mine: # A name_new transaction isn't expected to have a name input, # so we don't consider it a transfer. if name_op["op"] != OP_NAME_NEW: + is_relevant, is_mine, amount, fee = wallet.get_wallet_delta(tx) + if amount < 0: + return "Purchase: " + format_name_identifier(name_op["name"]) return "Transfer (Incoming): " + format_name_identifier(name_op["name"]) if name_op["op"] == OP_NAME_NEW: # Get the address where the NAME_NEW was sent to diff --git a/electrum_nmc/electrum/transaction.py b/electrum_nmc/electrum/transaction.py index 5708998ddcf0..06a172a6f0ea 100644 --- a/electrum_nmc/electrum/transaction.py +++ b/electrum_nmc/electrum/transaction.py @@ -1879,6 +1879,11 @@ def set_rbf(self, rbf: bool) -> None: def BIP69_sort(self, inputs=True, outputs=True): # NOTE: other parts of the code rely on these sorts being *stable* sorts + # Don't sort if any of the inputs are SIGHASH_SINGLE, since re-ordering + # those will change semantics. + # TODO: Sort the non-SINGLE inputs/outputs even if some are SINGLE. + if any([(i.sighash is not None and i.sighash & Sighash.SINGLE) for i in self.inputs()]): + return if inputs: self._inputs.sort(key = lambda i: (i.prevout.txid, i.prevout.out_idx)) if outputs: diff --git a/electrum_nmc/electrum/wallet.py b/electrum_nmc/electrum/wallet.py index 86d628e60980..11d95c259950 100644 --- a/electrum_nmc/electrum/wallet.py +++ b/electrum_nmc/electrum/wallet.py @@ -1157,7 +1157,7 @@ def get_new_sweep_address_for_channel(self) -> str: def make_unsigned_transaction(self, *, coins: Sequence[PartialTxInput], outputs: List[PartialTxOutput], fee=None, - change_addr: str = None, is_sweep=False, name_inputs=[]) -> PartialTransaction: + change_addr: str = None, is_sweep=False, name_inputs: Sequence[PartialTxInput] = []) -> PartialTransaction: if any([c.already_has_some_signatures() for c in coins]): raise Exception("Some inputs already contain signatures!") @@ -2120,7 +2120,7 @@ def get_all_known_addresses_beyond_gap_limit(self) -> Set[str]: def create_transaction(self, outputs, *, fee=None, feerate=None, change_addr=None, domain_addr=None, domain_coins=None, unsigned=False, rbf=None, password=None, locktime=None, - name_input_txids=[], name_input_identifiers=[]): + name_input_txids=[], name_input_identifiers=[], name_inputs_raw=[]): if fee is not None and feerate is not None: raise Exception("Cannot specify both 'fee' and 'feerate' at the same time!") @@ -2144,6 +2144,7 @@ def create_transaction(self, outputs, *, fee=None, feerate=None, change_addr=Non name_coins += self.get_spendable_coins(name_identifier_domain, include_names=True, only_uno_identifiers=name_input_identifiers) if len(name_coins) != len(name_input_txids) + len(name_input_identifiers): raise Exception("Name input not spendable in wallet") + name_coins = name_inputs_raw + name_coins if feerate is not None: fee_per_kb = 1000 * Decimal(feerate) fee_estimator = partial(SimpleConfig.estimate_fee_for_feerate, fee_per_kb)