Skip to content

Commit 549c956

Browse files
committed
[WIP] add v2 integration tests
1 parent 2a95c3a commit 549c956

File tree

6 files changed

+250
-166
lines changed

6 files changed

+250
-166
lines changed

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ uniffi = { version = "0.29.1", features = ["build"] }
2323

2424
[dependencies]
2525
base64 = "0.22.1"
26-
bitcoin-ffi = { git = "https://github.com/bitcoindevkit/bitcoin-ffi.git", rev = "6b1d131" }
26+
bitcoin-ffi = { git = "https://github.com/benalleng/bitcoin-ffi.git", rev = "37f47c8" }
2727
hex = "0.4.3"
28+
lazy_static = "1.5.0"
2829
ohttp = { package = "bitcoin-ohttp", version = "0.6.0" }
2930
payjoin = { version = "0.23.0", features = ["v1", "v2", "io"] }
3031
payjoin-test-utils = { version = "0.0.0", optional = true }
32+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
3133
serde = { version = "1.0.200", features = ["derive"] }
3234
serde_json = "1.0.128"
3335
thiserror = "1.0.58"
@@ -42,7 +44,6 @@ bitcoincore-rpc = "0.19.0"
4244
http = "1"
4345
ohttp-relay = "0.0.8"
4446
rcgen = { version = "0.11" }
45-
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
4647
rustls = "0.22.2"
4748
testcontainers = "0.15.0"
4849
testcontainers-modules = { version = "0.1.3", features = ["redis"] }

python/requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
python-bitcoinlib==0.12.2
22
toml==0.10.2
33
yapf==0.43.0
4-
4+
httpx==0.28.1

python/test/payjoin_integration_test.py

Lines changed: 155 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
from binascii import unhexlify
33
import os
44
import sys
5+
import httpx
56

67
from payjoin import *
8+
from typing import Optional
79

810
# The below sys path setting is required to use the 'payjoin' module in the 'src' directory
911
# This script is in the 'tests' directory and the 'payjoin' module is in the 'src' directory
@@ -25,6 +27,11 @@
2527

2628
SelectParams("regtest")
2729

30+
def get_rpc_credentials_from_cookie(cookie_path):
31+
"""Reads the RPC credentials from the cookie file"""
32+
with open(cookie_path, "r") as cookie_file:
33+
credentials = cookie_file.read().strip()
34+
return credentials.split(":")
2835

2936
# Function to create and load a wallet if it doesn't already exist
3037
def create_and_load_wallet(rpc_connection, wallet_name):
@@ -47,141 +54,163 @@ def create_and_load_wallet(rpc_connection, wallet_name):
4754
rpc_password = os.environ.get("RPC_PASSWORD", "123")
4855
rpc_host = os.environ.get("RPC_HOST", "localhost")
4956
rpc_port = os.environ.get("RPC_PORT", "18443")
50-
51-
52-
class TestPayjoin(unittest.TestCase):
57+
#ensure this is where your access cookie is located
58+
rpc_data_dir = os.environ.get("RPC_DATA_DIR", "~/.bitcoin/regtest")
59+
cookie_path = os.path.expanduser(os.path.join(rpc_data_dir, ".cookie"))
60+
rpc_user, rpc_password = get_rpc_credentials_from_cookie(cookie_path)
61+
62+
class InMemoryReceiverPersister(payjoin_ffi.ReceiverPersister):
63+
def __init__(self):
64+
super().__init__()
65+
self.receivers = {}
66+
67+
def save(self, receiver: Receiver) -> ReceiverToken:
68+
self.receivers[receiver.key().as_string()] = receiver.to_json()
69+
70+
return receiver.key()
71+
72+
def load(self, token: ReceiverToken) -> Receiver:
73+
token = token.as_string()
74+
if token not in self.receivers.keys():
75+
raise ValueError(f"Token not found: {token}")
76+
return Receiver.from_json(self.receivers[token])
77+
78+
class InMemorySenderPersister(payjoin_ffi.SenderPersister):
79+
def __init__(self):
80+
super().__init__()
81+
self.senders = {}
82+
83+
def save(self, sender: Sender) -> SenderToken:
84+
self.senders[sender.key().as_string()] = sender.to_json()
85+
return sender.key()
86+
87+
def load(self, token: SenderToken) -> Sender:
88+
token = token.as_string()
89+
if token not in self.senders.keys():
90+
raise ValueError(f"Token not found: {token}")
91+
return Sender.from_json(self.senders[token])
92+
93+
class TestPayjoin(unittest.IsolatedAsyncioTestCase):
5394
@classmethod
5495
def setUpClass(cls):
5596
# Initialize wallets once before all tests
5697
sender_wallet_name = "sender"
5798
sender_rpc_url = f"http://{rpc_user}:{rpc_password}@{rpc_host}:{rpc_port}/wallet/{sender_wallet_name}"
5899
cls.sender = Proxy(service_url=sender_rpc_url)
59100
create_and_load_wallet(cls.sender, sender_wallet_name)
60-
101+
61102
receiver_wallet_name = "receiver"
62103
receiver_rpc_url = f"http://{rpc_user}:{rpc_password}@{rpc_host}:{rpc_port}/wallet/{receiver_wallet_name}"
63104
cls.receiver = Proxy(service_url=receiver_rpc_url)
64105
create_and_load_wallet(cls.receiver, receiver_wallet_name)
106+
65107

66-
def test_integration(self):
67-
# Generate a new address for the sender
68-
sender_address = self.sender.getnewaddress()
69-
print(f"\nsender_address: {sender_address}")
70-
71-
# Generate a new address for the receiver
72-
receiver_address = self.receiver.getnewaddress()
73-
print(f"\nreceiver_address: {receiver_address}")
74-
75-
self.sender.generatetoaddress(101, str(sender_address))
76-
self.receiver.generatetoaddress(101, str(receiver_address))
77-
78-
# Fetch and print the balance of the sender address
79-
sender_balance = self.sender.getbalance()
80-
print(f"Sender address balance: {sender_balance}")
81-
82-
# Fetch and print the balance of the receiver address
83-
receiver_balance = self.receiver.getbalance()
84-
print(f"Receiver address balance: {receiver_balance}")
85-
86-
pj_uri_address = self.receiver.getnewaddress()
87-
pj_uri_string = "{}?amount={}&pj=https://example.com".format(
88-
f"bitcoin:{str(pj_uri_address)}", 1
89-
)
90-
prj_uri = Uri.from_str(pj_uri_string).check_pj_supported()
91-
print(f"\nprj_uri: {prj_uri.as_string()}")
92-
outputs = {}
93-
outputs[prj_uri.address()] = prj_uri.amount()
94-
pre_processed_psbt = self.sender._call(
95-
"walletcreatefundedpsbt",
96-
[],
97-
outputs,
98-
0,
99-
{"lockUnspents": True, "feeRate": 0.000020},
100-
)["psbt"]
101-
processed_psbt_base64 = self.sender._call("walletprocesspsbt", pre_processed_psbt)[
102-
"psbt"
103-
]
104-
req_ctx = RequestBuilder.from_psbt_and_uri(processed_psbt_base64, prj_uri ).build_with_additional_fee(10000, None, 0, False).extract_v1()
105-
req = req_ctx.request
106-
ctx = req_ctx.context_v1
107-
headers = Headers.from_vec(req.body)
108-
# **********************
109-
# Inside the Receiver:
110-
# this data would transit from one party to another over the network in production
111-
response = self.handle_pj_request(
112-
req=req,
113-
headers=headers,
114-
connection=self.receiver,
115-
)
116-
# this response would be returned as http response to the sender
117-
118-
# **********************
119-
# Inside the Sender:
120-
# Sender checks, signs, finalizes, extracts, and broadcasts
121-
checked_payjoin_proposal_psbt = ctx.process_response(bytes(response, encoding='utf8'))
122-
payjoin_processed_psbt = self.sender._call(
123-
"walletprocesspsbt",
124-
checked_payjoin_proposal_psbt,
125-
)["psbt"]
126-
127-
payjoin_tx_hex = self.sender._call(
128-
"finalizepsbt",
129-
payjoin_processed_psbt,
130-
)["hex"]
131-
132-
txid = self.sender._call("sendrawtransaction", payjoin_tx_hex)
133-
print(f"\nBroadcast sucessful. Txid: {txid}")
134-
135-
def handle_pj_request(self, req: Request, headers: Headers, connection: Proxy):
136-
proposal = UncheckedProposal.from_request(req.body, req.url.query(), headers)
137-
_to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast()
138-
maybe_inputs_owned = proposal.check_broadcast_suitability(None,
139-
can_broadcast=MempoolAcceptanceCallback(connection=connection)
140-
)
141-
142-
mixed_inputs_scripts = maybe_inputs_owned.check_inputs_not_owned(
143-
ScriptOwnershipCallback(connection)
144-
)
145-
inputs_seen = mixed_inputs_scripts.check_no_mixed_input_scripts()
146-
payjoin = inputs_seen.check_no_inputs_seen_before(
147-
OutputOwnershipCallback()
148-
).identify_receiver_outputs(ScriptOwnershipCallback(connection))
149-
available_inputs = connection._call("listunspent")
150-
candidate_inputs = {
151-
int(int(i["amount"] * 100000000)): OutPoint(txid=(str(i["txid"])), vout=i["vout"])
152-
for i in available_inputs
153-
}
154-
155-
selected_outpoint = payjoin.try_preserving_privacy(
156-
candidate_inputs=candidate_inputs
157-
)
158-
159-
selected_utxo = next(
160-
(
161-
i
162-
for i in available_inputs
163-
if i["txid"] == selected_outpoint.txid
164-
and i["vout"] == selected_outpoint.vout
165-
),
166-
None,
167-
)
168-
169-
txo_to_contribute = TxOut(
170-
value=int(selected_utxo["amount"] * 100000000),
171-
script_pubkey=[int(byte) for byte in unhexlify(selected_utxo["scriptPubKey"])]
172-
)
173-
outpoint_to_contribute = OutPoint(
174-
txid=selected_utxo["txid"], vout=int(selected_utxo["vout"])
175-
)
176-
payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute)
177-
payjoin_proposal = payjoin.finalize_proposal(
178-
ProcessPartiallySignedTransactionCallBack(connection=connection),
179-
1,
180-
)
181-
psbt = payjoin_proposal.psbt()
182-
print(f"\n Receiver's Payjoin proposal PSBT: {psbt}")
183-
return psbt
184-
108+
async def test_integration_v2_to_v2(self):
109+
try:
110+
receiver_address = bitcoin.Address(str(self.receiver.getnewaddress()), bitcoin.Network.REGTEST)
111+
init_tracing()
112+
services = TestServices.initialize()
113+
114+
# agent = services.http_agent()
115+
services.wait_for_services_ready()
116+
directory = services.directory_url()
117+
ohttp_keys = services.fetch_ohttp_keys()
118+
119+
# **********************
120+
# Inside the Receiver:
121+
expiry: Optional[int] = None
122+
new_receiver = NewReceiver(receiver_address, directory.as_string(), ohttp_keys, expiry)
123+
persister = InMemoryReceiverPersister()
124+
token = new_receiver.persist(persister)
125+
session: Receiver = Receiver.load(token, persister)
126+
print(f"session: {session.to_json()}")
127+
# Poll receive request
128+
ohttp_relay = services.ohttp_relay_url()
129+
request: RequestResponse = session.extract_req(ohttp_relay.as_string())
130+
agent = httpx.AsyncClient()
131+
response = await agent.post(
132+
url=request.request.url.as_string(),
133+
headers={"Content-Type": request.request.content_type},
134+
content=request.request.body
135+
)
136+
response_body = session.process_res(response.content, request.client_response)
137+
# No proposal yet since sender has not responded
138+
self.assertIsNone(response_body)
139+
140+
# **********************
141+
# Inside the Sender:
142+
# Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri
143+
pj_uri = session.pj_uri()
144+
outputs = {}
145+
outputs[pj_uri.address()] = 50
146+
psbt = self.sender._call(
147+
"walletcreatefundedpsbt",
148+
[],
149+
outputs,
150+
0,
151+
{"lockUnspents": True, "feeRate": 0.000020},
152+
)["psbt"]
153+
new_sender = SenderBuilder(psbt, pj_uri).build_recommended(1000)
154+
persister = InMemorySenderPersister()
155+
token = new_sender.persist(persister)
156+
req_ctx: Sender = Sender.load(token, persister)
157+
request: RequestV2PostContext = req_ctx.extract_v2(ohttp_relay)
158+
response = await agent.post(
159+
url=request.request.url.as_string(),
160+
headers={"Content-Type": request.request.content_type},
161+
content=request.request.body
162+
)
163+
send_ctx: V2GetContext = request.context.process_response(response.content)
164+
# POST Original PSBT
165+
166+
# **********************
167+
# Inside the Receiver:
168+
169+
# GET fallback psbt
170+
request: RequestResponse = session.extract_req(ohttp_relay.as_string())
171+
response = await agent.post(
172+
url=request.request.url.as_string(),
173+
headers={"Content-Type": request.request.content_type},
174+
content=request.request.body
175+
)
176+
# POST payjoin
177+
#proposal = session.process_res(response.content, request.client_response)
178+
#pj_proposal = proposal
179+
#payjoin_proposal = handle_directory_proposal(receiver, proposal, None)
180+
#request: RequestResponse = payjoin_proposal.extract_req(ohttp_relay.as_string())
181+
#response = await agent.post(
182+
# url=request.request.url.as_string(),
183+
# headers={"Content-Type": request.request.content_type},
184+
# content=request.request.body
185+
#)
186+
#payjoin_proposal.process_res(response, request.client_response)
187+
188+
# **********************
189+
# Inside the Sender:
190+
# Sender checks, signs, finalizes, extracts, and broadcasts
191+
# Replay post fallback to get the response
192+
request: RequestOhttpContext = send_ctx.extract_req(ohttp_relay.as_string())
193+
response = await agent.post(
194+
url=request.request.url.as_string(),
195+
headers={"Content-Type": request.request.content_type},
196+
content=request.request.body
197+
)
198+
checked_payjoin_proposal_psbt: Optional[str] = send_ctx.process_response(response.content, request.ohttp_ctx)
199+
await sleep(10)
200+
self.assertIsNotNone(checked_payjoin_proposal_psbt)
201+
payjoin_tx = bitcoin.Psbt.extract_tx(checked_payjoin_proposal_psbt)
202+
self.sender.sendrawtransaction(payjoin_tx)
203+
204+
# Check resulting transaction and balances
205+
network_fees = bitcoin.blockdata.predicted_tx_weight(payjoin_tx) * 1000;
206+
# Sender sent the entire value of their utxo to receiver (minus fees)
207+
self.assertEqual(payjoin_tx.input.len(), 2);
208+
self.assertEqual(payjoin_tx.output.len(), 1);
209+
self.assertEqual(self.receiver.getbalance(), bitcoin.Amount.from_btc(100.0) - network_fees)
210+
self.assertEqual(self.sender.getbalance(), bitcoin.Amount.from_btc(0.0))
211+
except Exception as e:
212+
print("Caught:", e)
213+
raise
185214

186215
class ProcessPartiallySignedTransactionCallBack:
187216
def __init__(self, connection: Proxy):
@@ -211,9 +240,9 @@ def callback(self, tx):
211240
return None
212241

213242

214-
class OutputOwnershipCallback(IsOutputKnown):
215-
def callback(self, outpoint: OutPoint):
216-
return False
243+
# class OutputOwnershipCallback(IsOutputKnown):
244+
# def callback(self, outpoint: OutPoint):
245+
# return False
217246

218247

219248
class ScriptOwnershipCallback(IsScriptOwned):

src/receive/uni.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,7 @@ impl Receiver {
119119
persister: Arc<dyn ReceiverPersister>,
120120
) -> Result<Self, ImplementationError> {
121121
Ok(super::Receiver::from(
122-
<<<<<<< HEAD
123122
(*persister.load(token).map_err(|e| ImplementationError::from(e.to_string()))?).clone(),
124-
=======
125-
persister.load(token).map_err(|e| ImplementationError::from(e.to_string()))?,
126-
>>>>>>> ff988e3 (Handle internal errors on `load()`)
127123
)
128124
.into())
129125
}

0 commit comments

Comments
 (0)