You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
226 lines
9.8 KiB
226 lines
9.8 KiB
|
|
""" |
|
Test doing payjoins over tcp client/server |
|
""" |
|
|
|
import os |
|
|
|
import pytest |
|
from twisted.internet import reactor |
|
from twisted.web.server import Site |
|
from twisted.web.client import readBody |
|
from twisted.web.http_headers import Headers |
|
from twisted.trial import unittest |
|
import urllib.parse as urlparse |
|
from urllib.parse import urlencode |
|
|
|
from jmbase import (get_log, jmprint, BytesProducer, |
|
JMHTTPResource, get_nontor_agent, |
|
wrapped_urlparse) |
|
from jmbitcoin import (encode_bip21_uri, |
|
amount_to_btc, amount_to_sat) |
|
from jmclient import (load_test_config, jm_single, |
|
SegwitLegacyWallet, SegwitWallet, |
|
parse_payjoin_setup, |
|
JMBIP78ReceiverManager) |
|
from jmclient.payjoin import make_payjoin_request_params, make_payment_psbt |
|
from jmclient.payjoin import process_payjoin_proposal_from_server |
|
from commontest import make_wallets |
|
from test_coinjoin import make_wallets_to_list, sync_wallets |
|
|
|
pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind") |
|
|
|
testdir = os.path.dirname(os.path.realpath(__file__)) |
|
log = get_log() |
|
|
|
class DummyBIP78ReceiverResource(JMHTTPResource): |
|
""" A simplified version of the BIP78Resource object created |
|
to serve requests in jmdaemon. |
|
""" |
|
def __init__(self, info_callback, shutdown_callback, bip78receivermanager): |
|
assert isinstance(bip78receivermanager, JMBIP78ReceiverManager) |
|
self.bip78_receiver_manager = bip78receivermanager |
|
self.info_callback = info_callback |
|
self.shutdown_callback = shutdown_callback |
|
super().__init__(info_callback, shutdown_callback) |
|
|
|
def render_POST(self, request): |
|
proposed_tx = request.content |
|
payment_psbt_base64 = proposed_tx.read().decode("utf-8") |
|
retval = self.bip78_receiver_manager.receive_proposal_from_sender( |
|
payment_psbt_base64, request.args) |
|
assert retval[0] |
|
content = retval[1].encode("utf-8") |
|
request.setHeader(b"content-length", ("%d" % len(content))) |
|
return content |
|
|
|
class PayjoinTestBase(object): |
|
""" This tests that a payjoin invoice and |
|
then payment of the invoice, results in the correct changes |
|
in balance in the sender and receiver wallets, while also |
|
implicitly testing all the BIP78 rules (failures are caught |
|
by the JMPayjoinManager and PayjoinConverter rules). |
|
""" |
|
# the indices in our wallets to populate |
|
wallet_structure = [1, 3, 0, 0, 0] |
|
# the mean amount of each deposit in the above indices, in btc |
|
mean_amt = 2.0 |
|
def setUp(self): |
|
load_test_config() |
|
jm_single().bc_interface.tick_forward_chain_interval = 5 |
|
jm_single().bc_interface.simulate_blocks() |
|
|
|
def do_test_payment(self, wc1, wc2, amt=1.1): |
|
wallet_structures = [self.wallet_structure] * 2 |
|
wallet_cls = (wc1, wc2) |
|
self.wallet_services = [] |
|
self.wallet_services.append(make_wallets_to_list(make_wallets( |
|
1, wallet_structures=[wallet_structures[0]], |
|
mean_amt=self.mean_amt, wallet_cls=wallet_cls[0]))[0]) |
|
self.wallet_services.append(make_wallets_to_list(make_wallets( |
|
1, wallet_structures=[wallet_structures[1]], |
|
mean_amt=self.mean_amt, wallet_cls=wallet_cls[1]))[0]) |
|
jm_single().bc_interface.tickchain() |
|
sync_wallets(self.wallet_services) |
|
|
|
# For accounting purposes, record the balances |
|
# at the start. |
|
self.rsb = getbals(self.wallet_services[0], 0) |
|
self.ssb = getbals(self.wallet_services[1], 0) |
|
|
|
self.cj_amount = int(amt * 10**8) |
|
def cbStopListening(): |
|
return self.port.stopListening() |
|
b78rm = JMBIP78ReceiverManager(self.wallet_services[0], 0, |
|
self.cj_amount, 47083) |
|
resource = DummyBIP78ReceiverResource(jmprint, cbStopListening, b78rm) |
|
self.site = Site(resource) |
|
self.site.displayTracebacks = False |
|
# NB The connectivity aspects of the onion-based BIP78 setup |
|
# are time heavy. This server is TCP only. |
|
self.port = reactor.listenTCP(47083, self.site) |
|
self.addCleanup(cbStopListening) |
|
|
|
# setup of spender |
|
bip78_btc_amount = amount_to_btc(amount_to_sat(self.cj_amount)) |
|
bip78_uri = encode_bip21_uri(str(b78rm.receiving_address), |
|
{"amount": bip78_btc_amount, |
|
"pj": b"http://127.0.0.1:47083"}, |
|
safe=":/") |
|
self.manager = parse_payjoin_setup(bip78_uri, self.wallet_services[1], 0) |
|
self.manager.mode = "testing" |
|
success, msg = make_payment_psbt(self.manager) |
|
assert success, msg |
|
params = make_payjoin_request_params(self.manager) |
|
# avoiding backend daemon (testing only jmclient code here), |
|
# we send the http request manually: |
|
serv = b"http://127.0.0.1:47083" |
|
agent = get_nontor_agent() |
|
body = BytesProducer(self.manager.initial_psbt.to_base64().encode("utf-8")) |
|
url_parts = list(wrapped_urlparse(serv)) |
|
url_parts[4] = urlencode(params).encode("utf-8") |
|
destination_url = urlparse.urlunparse(url_parts) |
|
d = agent.request(b"POST", destination_url, |
|
Headers({"Content-Type": ["text/plain"]}), |
|
bodyProducer=body) |
|
d.addCallback(bip78_receiver_response, self.manager) |
|
return d |
|
|
|
def tearDown(self): |
|
for dc in reactor.getDelayedCalls(): |
|
dc.cancel() |
|
res = final_checks(self.wallet_services, self.cj_amount, |
|
self.manager.final_psbt.get_fee(), |
|
self.ssb, self.rsb) |
|
assert res, "final checks failed" |
|
|
|
class TrialTestPayjoin1(PayjoinTestBase, unittest.TestCase): |
|
def test_payment(self): |
|
return self.do_test_payment(SegwitLegacyWallet, SegwitLegacyWallet) |
|
|
|
class TrialTestPayjoin2(PayjoinTestBase, unittest.TestCase): |
|
def test_bech32_payment(self): |
|
return self.do_test_payment(SegwitWallet, SegwitWallet) |
|
|
|
class TrialTestPayjoin3(PayjoinTestBase, unittest.TestCase): |
|
def test_multi_input(self): |
|
# wallet structure and amt are chosen so that the sender |
|
# will need 3 utxos rather than 1 (to pay 4.5 from 2,2,2). |
|
self.wallet_structure = [3, 1, 0, 0, 0] |
|
return self.do_test_payment(SegwitWallet, SegwitWallet, amt=4.5) |
|
|
|
class TrialTestPayjoin4(PayjoinTestBase, unittest.TestCase): |
|
def reset_fee(self, res): |
|
jm_single().config.set("POLICY", "txfees", self.old_txfees) |
|
def test_low_feerate(self): |
|
self.old_txfees = jm_single().config.get("POLICY", "tx_fees") |
|
# To set such that randomization cannot pull it below minfeerate |
|
# (default of 1.1 sat/vbyte), we should need tx_fees at 1376 |
|
# (which corresponds to 1.108); but given the minor potential deviations |
|
# as noted in https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/babad1963992965e933924b6c306ad9da89989e0/jmclient/jmclient/payjoin.py#L802-L809 |
|
# , we increase from that by 2%. |
|
jm_single().config.set("POLICY", "tx_fees", "1404") |
|
d = self.do_test_payment(SegwitWallet, SegwitWallet) |
|
d.addCallback(self.reset_fee) |
|
return d |
|
|
|
def bip78_receiver_response(response, manager): |
|
d = readBody(response) |
|
# if the response code is not 200 OK, we must assume payjoin |
|
# attempt has failed, and revert to standard payment. |
|
if int(response.code) != 200: |
|
d.addCallback(process_receiver_errormsg, response.code) |
|
return |
|
d.addCallback(process_receiver_psbt, manager) |
|
|
|
def process_receiver_errormsg(r, c): |
|
print("Failed: r, c: ", r, c) |
|
assert False |
|
|
|
def process_receiver_psbt(response, manager): |
|
process_payjoin_proposal_from_server(response.decode("utf-8"), manager) |
|
|
|
def getbals(wallet_service, mixdepth): |
|
""" Retrieves balances for a mixdepth and the 'next' |
|
""" |
|
bbm = wallet_service.get_balance_by_mixdepth() |
|
return (bbm[mixdepth], bbm[(mixdepth + 1) % (wallet_service.mixdepth + 1)]) |
|
|
|
def final_checks(wallet_services, amount, txfee, ssb, rsb, source_mixdepth=0): |
|
"""We use this to check that the wallet contents are |
|
as we've expected according to the test case. |
|
amount is the payment amount going from spender to receiver. |
|
txfee is the bitcoin network transaction fee, paid by the spender. |
|
ssb, rsb are spender and receiver starting balances, each a tuple |
|
of two entries, source and destination mixdepth respectively. |
|
""" |
|
jm_single().bc_interface.tickchain() |
|
sync_wallets(wallet_services) |
|
spenderbals = getbals(wallet_services[1], source_mixdepth) |
|
receiverbals = getbals(wallet_services[0], source_mixdepth) |
|
# is the payment received? |
|
receiver_newcoin_amt = receiverbals[1] - rsb[1] |
|
if not receiver_newcoin_amt >= amount: |
|
print("Receiver expected to receive at least: ", amount, |
|
" but got: ", receiver_newcoin_amt) |
|
return False |
|
# assert that the receiver received net exactly the right amount |
|
receiver_spentcoin_amt = rsb[0] - receiverbals[0] |
|
if not receiver_spentcoin_amt >= 0: |
|
# for now allow the non-cj fallback case |
|
print("receiver's spent coin should have been positive, was: ", receiver_spentcoin_amt) |
|
return False |
|
if not receiver_newcoin_amt == amount + receiver_spentcoin_amt: |
|
print("receiver's new coins should have been: ", amount + receiver_spentcoin_amt, |
|
" but was: ", receiver_newcoin_amt) |
|
return False |
|
|
|
# Spender-side check |
|
# assert that the spender's total ending minus total starting |
|
# balance is the amount plus the txfee given. |
|
if not (sum(spenderbals) - sum(ssb) + txfee + amount) == 0: |
|
print("Taker should have spent: ", txfee + amount, |
|
" but spent: ", sum(ssb) - sum(spenderbals)) |
|
return False |
|
print("Final checks were passed") |
|
return True
|
|
|