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

Add atomic name trades #325

Open
wants to merge 5 commits into
base: master-4.0.2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
287 changes: 286 additions & 1 deletion electrum_nmc/electrum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1055,6 +1055,290 @@ 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:
try:
# TODO: handle semi-expired names
show_results = await self.name_show(identifier, name_encoding=name_encoding.value, value_encoding=value_encoding.value, wallet=wallet)
except NameUnconfirmedError:
# 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.
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")

if amount == 0.0:
raise Exception("Must specify amount")
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")

if amount == 0.0:
raise Exception("Must specify amount")
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 output 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."""
Expand Down Expand Up @@ -1931,6 +2215,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"),
Expand Down
6 changes: 6 additions & 0 deletions electrum_nmc/electrum/names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions electrum_nmc/electrum/tests/regtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ def test_name_registration(self):
def test_name_autoregister(self):
self.run_shell(['name_autoregister'])

def test_name_ant_workflow(self):
self.run_shell(['name_ant_workflow'])


class TestNamecoinABSegwitSeed(TestNamecoinABDefaultSeed):
create_opts = "--seed_type segwit"
Expand Down
57 changes: 57 additions & 0 deletions electrum_nmc/electrum/tests/regtest/regtest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -681,3 +681,60 @@ if [[ $1 == "name_autoregister" ]]; then

echo "TODO: Finish these tests"
fi

if [[ $1 == "name_ant_workflow" ]]; then
# Expire any existing names from previous functional test runs.
new_blocks 35
wait_for_chain_sync "$alice"
wait_for_chain_sync "$bob"

echo "funding Bob"
$bitcoin_cli sendtoaddress $($bob getunusedaddress -o) 1

echo "Alice registers name"
$alice name_autoregister "x/name" --value "value"
sleep 5s
new_blocks 12
wait_for_chain_sync "$alice"
wait_for_chain_sync "$bob"
# TODO: Remove this line once daemon mode automatically does this.
$alice updatequeuedtransactions
sleep 5s
new_blocks 12
wait_for_chain_sync "$alice"
wait_for_chain_sync "$bob"
data_alice=$($alice name_show x/name -w /tmp/alice/regtest/wallets/default_wallet)
data_bob=$($bob name_show x/name -w /tmp/bob/regtest/wallets/default_wallet)
assert_equal "true" "$(echo $data_alice | jq -r .ismine)" "Newly registered Alice name doesn't belong to Alice"
assert_equal "false" "$(echo $data_bob | jq -r .ismine)" "Newly registered Alice name belongs to Bob"

echo "Alice sells to Bob"
offer=$($alice name_sell x/name --amount 0.02)
assert_raises_error "$bob name_buy x/name --amount 0.03 --value value2 --offer $offer" "price mismatch"
assert_raises_error "$bob name_buy x/name2 --amount 0.02 --value value2 --offer $offer" "name identifier mismatch"
completed_offer=$($bob name_buy x/name --amount 0.02 --value value2 --offer $offer)
$bob broadcast $completed_offer
sleep 5s
new_blocks 12
wait_for_chain_sync "$alice"
wait_for_chain_sync "$bob"
data_alice=$($alice name_show x/name -w /tmp/alice/regtest/wallets/default_wallet)
data_bob=$($bob name_show x/name -w /tmp/bob/regtest/wallets/default_wallet)
assert_equal "false" "$(echo $data_alice | jq -r .ismine)" "Transferred Bob name belongs to Alice"
assert_equal "true" "$(echo $data_bob | jq -r .ismine)" "Transferred Bob name doesn't belong to Bob"

echo "Alice buys from Bob"
offer=$($alice name_buy x/name --amount 0.02 --value value3)
assert_raises_error "$bob name_sell x/name --amount 0.03 --offer $offer" "price mismatch"
assert_raises_error "$bob name_sell x/name2 --amount 0.02 --offer $offer" "name identifier mismatch"
completed_offer=$($bob name_sell x/name --amount 0.02 --offer $offer)
$bob broadcast $completed_offer
sleep 5s
new_blocks 12
wait_for_chain_sync "$alice"
wait_for_chain_sync "$bob"
data_alice=$($alice name_show x/name -w /tmp/alice/regtest/wallets/default_wallet)
data_bob=$($bob name_show x/name -w /tmp/bob/regtest/wallets/default_wallet)
assert_equal "true" "$(echo $data_alice | jq -r .ismine)" "Transferred Alice name doesn't belong to Alice"
assert_equal "false" "$(echo $data_bob | jq -r .ismine)" "Transferred Alice name belongs to Bob"
fi
Loading