22from binascii import unhexlify
33import os
44import sys
5+ import httpx
56
67from 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
2527
2628SelectParams ("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
3037def create_and_load_wallet (rpc_connection , wallet_name ):
@@ -47,141 +54,163 @@ def create_and_load_wallet(rpc_connection, wallet_name):
4754rpc_password = os .environ .get ("RPC_PASSWORD" , "123" )
4855rpc_host = os .environ .get ("RPC_HOST" , "localhost" )
4956rpc_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"\n sender_address: { sender_address } " )
70-
71- # Generate a new address for the receiver
72- receiver_address = self .receiver .getnewaddress ()
73- print (f"\n receiver_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"\n prj_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"\n Broadcast 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
186215class 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
219248class ScriptOwnershipCallback (IsScriptOwned ):
0 commit comments