diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index 77592b7..313b0a0 100644 --- a/.github/workflows/build-python.yml +++ b/.github/workflows/build-python.yml @@ -3,30 +3,23 @@ name: Build and Test Python on: [push, pull_request] jobs: - build-manylinux_2_28-x86_64-wheels: - name: "Build and test Manylinux 2.28 x86_64 wheels" - runs-on: ubuntu-24.04 + build-wheels-and-test: + name: "Build and test wheels with Redis" + runs-on: ubuntu-latest + services: + redis: + image: redis:7-alpine defaults: run: working-directory: python - container: - image: quay.io/pypa/manylinux_2_28_x86_64 - env: - PLAT: manylinux_2_28_x86_64 - PYBIN: "/opt/python/${{ matrix.python_dir }}/bin" strategy: matrix: include: - python: "3.9" - python_dir: "cp39-cp39" - python: "3.10" - python_dir: "cp310-cp310" - python: "3.11" - python_dir: "cp311-cp311" - python: "3.12" - python_dir: "cp312-cp312" - python: "3.13" - python_dir: "cp313-cp313" steps: - name: "Checkout" uses: actions/checkout@v4 @@ -41,22 +34,29 @@ jobs: with: python-version: ${{ matrix.python }} + - name: "Install build dependencies" + run: | + sudo apt update + sudo apt install -y build-essential python3-dev + - name: "Use cache" uses: Swatinem/rust-cache@v2 - name: "Generate payjoin-ffi.py and binaries" - run: PYBIN="/opt/python/${{ matrix.python_dir }}/bin" bash ./scripts/generate_linux.sh + run: | + PYBIN=$(dirname $(which python)) + PYBIN="$PYBIN" bash ./scripts/generate_linux.sh - name: "Build wheel" - # Specifying the plat-name argument is necessary to build a wheel with the correct name, - # see issue BDK#350 for more information - run: ${PYBIN}/python setup.py bdist_wheel --plat-name $PLAT --verbose + run: python setup.py bdist_wheel --verbose - name: "Install wheel" - run: ${PYBIN}/pip install ./dist/*.whl + run: pip install ./dist/*.whl - name: "Run tests" - run: ${PYBIN}/python -m unittest --verbose test/payjoin_unit_test.py + env: + REDIS_URL: redis://localhost:6379 + run: python -m unittest -v build-macos: name: "Build and test macOS" @@ -84,6 +84,26 @@ jobs: with: python-version: ${{ matrix.python }} + - name: Setup Docker on macOS + uses: douglascamata/setup-docker-macos-action@v1.0.0 + + - name: "Install Redis" + run: | + brew update + brew install redis + + - name: "Start Redis" + run: | + redis-server --daemonize yes + for i in {1..10}; do + if redis-cli ping | grep -q PONG; then + echo "Redis is ready" + break + fi + echo "Waiting for Redis..." + sleep 1 + done + - name: "Use cache" uses: Swatinem/rust-cache@v2 @@ -91,16 +111,12 @@ jobs: run: bash ./scripts/generate_macos.sh - name: "Build wheel" - # Specifying the plat-name argument is necessary to build a wheel with the correct name, - # see issue BDK#350 for more information - run: python3 setup.py bdist_wheel --plat-name macosx_11_0_x86_64 --verbose + run: python3 setup.py bdist_wheel --verbose - name: "Install wheel" run: pip3 install ./dist/*.whl - name: "Run tests" - run: python3 -m unittest --verbose test/payjoin_unit_test.py - - - name: "Build arm64 wheel" - run: python3 setup.py bdist_wheel --plat-name macosx_11_0_arm64 --verbose - # Note: You can't install the arm64 wheel on the CI, so we skip these steps and simply test that the wheel builds + env: + REDIS_URL: redis://localhost:6379 + run: python3 -m unittest -v diff --git a/Cargo.lock b/Cargo.lock index f991606..531c57c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "bitcoin-ffi" version = "0.1.2" -source = "git+https://github.com/bitcoindevkit/bitcoin-ffi.git?rev=6b1d131#6b1d1315dff8696b5ffeb3e5669f308ade227749" +source = "git+https://github.com/benalleng/bitcoin-ffi.git?rev=8e3a23b#8e3a23b0369ac85a27ae33bac60130e9dc439179" dependencies = [ "bitcoin 0.32.5", "thiserror 1.0.69", @@ -1991,6 +1991,7 @@ dependencies = [ "bitcoind", "hex", "http", + "lazy_static", "ohttp-relay 0.0.8", "payjoin", "payjoin-test-utils", diff --git a/Cargo.toml b/Cargo.toml index be7fb8f..f32f9df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT OR Apache-2.0" exclude = ["tests"] [features] -_test-utils = ["payjoin-test-utils", "tokio"] +_test-utils = ["payjoin-test-utils", "tokio", "bitcoind"] _danger-local-https = ["payjoin/_danger-local-https"] uniffi = ["uniffi/cli", "bitcoin-ffi/default"] @@ -23,8 +23,10 @@ uniffi = { version = "0.29.1", features = ["build"] } [dependencies] base64 = "0.22.1" -bitcoin-ffi = { git = "https://github.com/bitcoindevkit/bitcoin-ffi.git", rev = "6b1d131" } +bitcoind = { version = "0.36.0", features = ["0_21_2"], optional = true } +bitcoin-ffi = { git = "https://github.com/benalleng/bitcoin-ffi.git", rev = "8e3a23b" } hex = "0.4.3" +lazy_static = "1.5.0" ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } payjoin = { version = "0.23.0", features = ["v1", "v2", "io"] } payjoin-test-utils = { version = "0.0.0", optional = true } @@ -37,7 +39,6 @@ url = "2.5.0" [dev-dependencies] bdk = { version = "0.29.0", features = ["all-keys", "use-esplora-ureq", "keys-bip39", "rpc"] } -bitcoind = { version = "0.36.0", features = ["0_21_2"] } bitcoincore-rpc = "0.19.0" http = "1" ohttp-relay = "0.0.8" diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt index 44ff5c1..b8f78f5 100644 --- a/python/requirements-dev.txt +++ b/python/requirements-dev.txt @@ -1,4 +1,4 @@ python-bitcoinlib==0.12.2 toml==0.10.2 yapf==0.43.0 - +httpx==0.28.1 diff --git a/python/test/payjoin_integration_test.py b/python/test/payjoin_integration_test.py deleted file mode 100644 index f4f29d4..0000000 --- a/python/test/payjoin_integration_test.py +++ /dev/null @@ -1,236 +0,0 @@ -import base64 -from binascii import unhexlify -import os -import sys - -from payjoin import * - -# The below sys path setting is required to use the 'payjoin' module in the 'src' directory -# This script is in the 'tests' directory and the 'payjoin' module is in the 'src' directory -sys.path.insert( - 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) -) - -import hashlib -import unittest -from pprint import * -from bitcoin import SelectParams -from bitcoin.core.script import ( - CScript, - OP_0, - SignatureHash, -) -from bitcoin.wallet import * -from bitcoin.rpc import Proxy, hexlify_str, JSONRPCError - -SelectParams("regtest") - - -# Function to create and load a wallet if it doesn't already exist -def create_and_load_wallet(rpc_connection, wallet_name): - try: - # Try to load the wallet using the _call method - rpc_connection._call("loadwallet", wallet_name) - print(f"Wallet '{wallet_name}' loaded successfully.") - except JSONRPCError as e: - # Check if the error code indicates the wallet does not exist - if e.error["code"] == -18: # Wallet not found error code - # Create the wallet since it does not exist using the _call method - rpc_connection._call("createwallet", wallet_name) - print(f"Wallet '{wallet_name}' created and loaded successfully.") - elif e.error["code"] == -35: # Wallet already loaded - print(f"Wallet '{wallet_name}' created and loaded successfully.") - - -# Set up RPC connections -rpc_user = os.environ.get("RPC_USER", "admin1") -rpc_password = os.environ.get("RPC_PASSWORD", "123") -rpc_host = os.environ.get("RPC_HOST", "localhost") -rpc_port = os.environ.get("RPC_PORT", "18443") - - -class TestPayjoin(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Initialize wallets once before all tests - sender_wallet_name = "sender" - sender_rpc_url = f"http://{rpc_user}:{rpc_password}@{rpc_host}:{rpc_port}/wallet/{sender_wallet_name}" - cls.sender = Proxy(service_url=sender_rpc_url) - create_and_load_wallet(cls.sender, sender_wallet_name) - - receiver_wallet_name = "receiver" - receiver_rpc_url = f"http://{rpc_user}:{rpc_password}@{rpc_host}:{rpc_port}/wallet/{receiver_wallet_name}" - cls.receiver = Proxy(service_url=receiver_rpc_url) - create_and_load_wallet(cls.receiver, receiver_wallet_name) - - def test_integration(self): - # Generate a new address for the sender - sender_address = self.sender.getnewaddress() - print(f"\nsender_address: {sender_address}") - - # Generate a new address for the receiver - receiver_address = self.receiver.getnewaddress() - print(f"\nreceiver_address: {receiver_address}") - - self.sender.generatetoaddress(101, str(sender_address)) - self.receiver.generatetoaddress(101, str(receiver_address)) - - # Fetch and print the balance of the sender address - sender_balance = self.sender.getbalance() - print(f"Sender address balance: {sender_balance}") - - # Fetch and print the balance of the receiver address - receiver_balance = self.receiver.getbalance() - print(f"Receiver address balance: {receiver_balance}") - - pj_uri_address = self.receiver.getnewaddress() - pj_uri_string = "{}?amount={}&pj=https://example.com".format( - f"bitcoin:{str(pj_uri_address)}", 1 - ) - prj_uri = Uri.from_str(pj_uri_string).check_pj_supported() - print(f"\nprj_uri: {prj_uri.as_string()}") - outputs = {} - outputs[prj_uri.address()] = prj_uri.amount() - pre_processed_psbt = self.sender._call( - "walletcreatefundedpsbt", - [], - outputs, - 0, - {"lockUnspents": True, "feeRate": 0.000020}, - )["psbt"] - processed_psbt_base64 = self.sender._call("walletprocesspsbt", pre_processed_psbt)[ - "psbt" - ] - req_ctx = RequestBuilder.from_psbt_and_uri(processed_psbt_base64, prj_uri ).build_with_additional_fee(10000, None, 0, False).extract_v1() - req = req_ctx.request - ctx = req_ctx.context_v1 - headers = Headers.from_vec(req.body) - # ********************** - # Inside the Receiver: - # this data would transit from one party to another over the network in production - response = self.handle_pj_request( - req=req, - headers=headers, - connection=self.receiver, - ) - # this response would be returned as http response to the sender - - # ********************** - # Inside the Sender: - # Sender checks, signs, finalizes, extracts, and broadcasts - checked_payjoin_proposal_psbt = ctx.process_response(bytes(response, encoding='utf8')) - payjoin_processed_psbt = self.sender._call( - "walletprocesspsbt", - checked_payjoin_proposal_psbt, - )["psbt"] - - payjoin_tx_hex = self.sender._call( - "finalizepsbt", - payjoin_processed_psbt, - )["hex"] - - txid = self.sender._call("sendrawtransaction", payjoin_tx_hex) - print(f"\nBroadcast sucessful. Txid: {txid}") - - def handle_pj_request(self, req: Request, headers: Headers, connection: Proxy): - proposal = UncheckedProposal.from_request(req.body, req.url.query(), headers) - _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast() - maybe_inputs_owned = proposal.check_broadcast_suitability(None, - can_broadcast=MempoolAcceptanceCallback(connection=connection) - ) - - mixed_inputs_scripts = maybe_inputs_owned.check_inputs_not_owned( - ScriptOwnershipCallback(connection) - ) - inputs_seen = mixed_inputs_scripts.check_no_mixed_input_scripts() - payjoin = inputs_seen.check_no_inputs_seen_before( - OutputOwnershipCallback() - ).identify_receiver_outputs(ScriptOwnershipCallback(connection)) - available_inputs = connection._call("listunspent") - candidate_inputs = { - int(int(i["amount"] * 100000000)): OutPoint(txid=(str(i["txid"])), vout=i["vout"]) - for i in available_inputs - } - - selected_outpoint = payjoin.try_preserving_privacy( - candidate_inputs=candidate_inputs - ) - - selected_utxo = next( - ( - i - for i in available_inputs - if i["txid"] == selected_outpoint.txid - and i["vout"] == selected_outpoint.vout - ), - None, - ) - - txo_to_contribute = TxOut( - value=int(selected_utxo["amount"] * 100000000), - script_pubkey=[int(byte) for byte in unhexlify(selected_utxo["scriptPubKey"])] - ) - outpoint_to_contribute = OutPoint( - txid=selected_utxo["txid"], vout=int(selected_utxo["vout"]) - ) - payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute) - payjoin_proposal = payjoin.finalize_proposal( - ProcessPartiallySignedTransactionCallBack(connection=connection), - 1, - ) - psbt = payjoin_proposal.psbt() - print(f"\n Receiver's Payjoin proposal PSBT: {psbt}") - return psbt - - -class ProcessPartiallySignedTransactionCallBack: - def __init__(self, connection: Proxy): - self.connection = connection - - def callback(self, psbt: str): - try: - return self.connection._call( - "walletprocesspsbt", psbt, True, "NONE", False - )["psbt"] - except Exception as e: - print(f"An error occurred: {e}") - return None - - -class MempoolAcceptanceCallback(CanBroadcast): - def __init__(self, connection: Proxy): - self.connection = connection - - def callback(self, tx): - try: - return self.connection._call("testmempoolaccept", [bytes(tx).hex()])[0][ - "allowed" - ] - except Exception as e: - print(f"An error occurred: {e}") - return None - - -class OutputOwnershipCallback(IsOutputKnown): - def callback(self, outpoint: OutPoint): - return False - - -class ScriptOwnershipCallback(IsScriptOwned): - def __init__(self, connection: Proxy): - self.connection = connection - - def callback(self, script): - try: - script = CScript(bytes(script)) - witness_program = script[2:] - address = P2WPKHBitcoinAddress.from_bytes(0, witness_program) - return self.connection._call("getaddressinfo", str(address))["ismine"] - except Exception as e: - print(f"An error occurred: {e}") - return None - - - -if __name__ == "__main__": - unittest.main() diff --git a/python/test/test_payjoin_integration_test.py b/python/test/test_payjoin_integration_test.py new file mode 100644 index 0000000..9735ad1 --- /dev/null +++ b/python/test/test_payjoin_integration_test.py @@ -0,0 +1,252 @@ +import base64 +from binascii import unhexlify +import os +import sys +import httpx +import json + +from payjoin import * +from typing import Optional +import payjoin.bitcoin as bitcoinffi + +# The below sys path setting is required to use the 'payjoin' module in the 'src' directory +# This script is in the 'tests' directory and the 'payjoin' module is in the 'src' directory +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +) + +import hashlib +import unittest +from pprint import * +from bitcoin import SelectParams +from bitcoin.core.script import ( + CScript, + OP_0, + SignatureHash, +) +from bitcoin.wallet import * +from bitcoin.rpc import Proxy, hexlify_str, JSONRPCError + +SelectParams("regtest") + +class InMemoryReceiverPersister(ReceiverPersister): + def __init__(self): + super().__init__() + self.receivers = {} + + def save(self, receiver: Receiver) -> ReceiverToken: + self.receivers[str(receiver.key())] = receiver.to_json() + + return receiver.key() + + def load(self, token: ReceiverToken) -> Receiver: + token = str(token) + if token not in self.receivers.keys(): + raise ValueError(f"Token not found: {token}") + return Receiver.from_json(self.receivers[token]) + +class InMemorySenderPersister(SenderPersister): + def __init__(self): + super().__init__() + self.senders = {} + + def save(self, sender: Sender) -> SenderToken: + self.senders[str(sender.key())] = sender.to_json() + return sender.key() + + def load(self, token: SenderToken) -> Sender: + token = str(token) + if token not in self.senders.keys(): + raise ValueError(f"Token not found: {token}") + return Sender.from_json(self.senders[token]) + +class TestPayjoin(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + cls.env = init_bitcoind_sender_receiver() + cls.bitcoind = cls.env.get_bitcoind() + cls.receiver = cls.env.get_receiver() + cls.sender = cls.env.get_sender() + + async def test_integration_v2_to_v2(self): + try: + receiver_address = bitcoinffi.Address(json.loads(self.receiver.call("getnewaddress", [])), bitcoinffi.Network.REGTEST) + init_tracing() + services = TestServices.initialize() + + services.wait_for_services_ready() + directory = services.directory_url() + ohttp_keys = services.fetch_ohttp_keys() + + # ********************** + # Inside the Receiver: + new_receiver = NewReceiver(receiver_address, directory.as_string(), ohttp_keys, None) + persister = InMemoryReceiverPersister() + token = new_receiver.persist(persister) + session: Receiver = Receiver.load(token, persister) + print(f"session: {session.to_json()}") + # Poll receive request + ohttp_relay = services.ohttp_relay_url() + request: RequestResponse = session.extract_req(ohttp_relay.as_string()) + agent = httpx.AsyncClient() + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + response_body = session.process_res(response.content, request.client_response) + # No proposal yet since sender has not responded + self.assertIsNone(response_body) + + # ********************** + # Inside the Sender: + # Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + pj_uri = session.pj_uri() + psbt = build_sweep_psbt(self.sender, pj_uri) + new_sender = SenderBuilder(psbt, pj_uri).build_recommended(1000) + persister = InMemorySenderPersister() + token = new_sender.persist(persister) + req_ctx: Sender = Sender.load(token, persister) + request: RequestV2PostContext = req_ctx.extract_v2(ohttp_relay) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + send_ctx: V2GetContext = request.context.process_response(response.content) + # POST Original PSBT + + # ********************** + # Inside the Receiver: + + # GET fallback psbt + request: RequestResponse = session.extract_req(ohttp_relay.as_string()) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + # POST payjoin + proposal = session.process_res(response.content, request.client_response) + payjoin_proposal = handle_directory_payjoin_proposal(self.receiver, proposal) + request: RequestResponse = payjoin_proposal.extract_req(ohttp_relay.as_string()) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + payjoin_proposal.process_res(response.content, request.client_response) + + # ********************** + # Inside the Sender: + # Sender checks, signs, finalizes, extracts, and broadcasts + # Replay post fallback to get the response + request: RequestOhttpContext = send_ctx.extract_req(ohttp_relay.as_string()) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + checked_payjoin_proposal_psbt: Optional[str] = send_ctx.process_response(response.content, request.ohttp_ctx) + self.assertIsNotNone(checked_payjoin_proposal_psbt) + payjoin_psbt = json.loads(self.sender.call("walletprocesspsbt", [checked_payjoin_proposal_psbt]))["psbt"] + final_psbt = json.loads(self.sender.call("finalizepsbt", [payjoin_psbt, json.dumps(False)]))["psbt"] + payjoin_tx = bitcoinffi.Psbt.deserialize_base64(final_psbt).extract_tx() + self.sender.call("sendrawtransaction", [json.dumps(payjoin_tx.serialize().hex())]) + + # Check resulting transaction and balances + network_fees = bitcoinffi.Psbt.deserialize_base64(final_psbt).fee().to_btc() + # Sender sent the entire value of their utxo to receiver (minus fees) + self.assertEqual(len(payjoin_tx.input()), 2); + self.assertEqual(len(payjoin_tx.output()), 1); + self.assertEqual(float(json.loads(self.receiver.call("getbalances", []))["mine"]["untrusted_pending"]), 100 - network_fees) + self.assertEqual(float(self.sender.call("getbalance", [])), 0) + return + except Exception as e: + print("Caught:", e) + raise + +def handle_directory_payjoin_proposal(receiver: Proxy, proposal: UncheckedProposal) -> PayjoinProposal: + maybe_inputs_owned = proposal.check_broadcast_suitability(None, MempoolAcceptanceCallback(receiver)) + maybe_inputs_seen = maybe_inputs_owned.check_inputs_not_owned(IsScriptOwnedCallback(receiver)) + outputs_unknown = maybe_inputs_seen.check_no_inputs_seen_before(CheckInputsNotSeenCallback(receiver)) + wants_outputs = outputs_unknown.identify_receiver_outputs(IsScriptOwnedCallback(receiver)) + wants_inputs = wants_outputs.commit_outputs() + provisional_proposal = wants_inputs.contribute_inputs(get_inputs(receiver)).commit_inputs() + return provisional_proposal.finalize_proposal(ProcessPsbtCallback(receiver), 1, 10) + +def build_sweep_psbt(sender: Proxy, pj_uri: PjUri) -> bitcoinffi.Psbt: + outputs = {} + outputs[pj_uri.address()] = 50 + psbt = json.loads(sender.call( + "walletcreatefundedpsbt", + [json.dumps([]), + json.dumps(outputs), + json.dumps(0), + json.dumps({"lockUnspents": True, "fee_rate": 10, "subtract_fee_from_outputs": [0]}) + ]))["psbt"] + return json.loads(sender.call("walletprocesspsbt", [psbt, json.dumps(True), json.dumps("ALL"), json.dumps(False)]))["psbt"] + +def get_inputs(rpc_connection: Proxy) -> list[InputPair]: + utxos = json.loads(rpc_connection.call("listunspent", [])) + inputs = [] + for utxo in utxos[:1]: + txin = bitcoinffi.TxIn( + previous_output=bitcoinffi.OutPoint(txid=utxo["txid"], vout=utxo["vout"]), + script_sig=bitcoinffi.Script(bytes()), + sequence=0, + witness=[] + ) + raw_tx = json.loads(rpc_connection.call("gettransaction", [json.dumps(utxo["txid"]), json.dumps(True), json.dumps(True)])) + prev_out = raw_tx["decoded"]["vout"][utxo["vout"]] + prev_spk = bitcoinffi.Script(bytes.fromhex(prev_out["scriptPubKey"]["hex"])) + prev_amount = bitcoinffi.Amount.from_btc(prev_out["value"]) + tx_out = bitcoinffi.TxOut(value=prev_amount, script_pubkey=prev_spk) + psbt_in = PsbtInput(witness_utxo=tx_out, redeem_script=None, witness_script=None) + inputs.append(InputPair(txin=txin, psbtin=psbt_in)) + + return inputs + +class MempoolAcceptanceCallback(CanBroadcast): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, tx): + try: + res = json.loads(self.connection.call("testmempoolaccept", [json.dumps([bytes(tx).hex()])]))[0][ + "allowed" + ] + return res + except Exception as e: + print(f"An error occurred: {e}") + return None + +class IsScriptOwnedCallback(IsScriptOwned): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, script): + try: + address = bitcoinffi.Address.from_script(bitcoinffi.Script(script), bitcoinffi.Network.REGTEST) + return json.loads(self.connection.call("getaddressinfo", [str(address)]))["ismine"] + except Exception as e: + print(f"An error occurred: {e}") + return None + +class CheckInputsNotSeenCallback(IsOutputKnown): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, _outpoint): + return False + +class ProcessPsbtCallback(ProcessPsbt): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, psbt: str): + res = json.loads(self.connection.call("walletprocesspsbt", [psbt])) + return res['psbt'] + +if __name__ == "__main__": + unittest.main() diff --git a/python/test/payjoin_unit_test.py b/python/test/test_payjoin_unit_test.py similarity index 100% rename from python/test/payjoin_unit_test.py rename to python/test/test_payjoin_unit_test.py diff --git a/src/error.rs b/src/error.rs index 5b730fc..9c11b48 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,13 +5,13 @@ pub struct SerdeJsonError(#[from] serde_json::Error); #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "uniffi", derive(uniffi::Error))] -pub enum PersistenceError { +pub enum ForeignError { #[error("Internal error: {0}")] InternalError(String), } #[cfg(feature = "uniffi")] -impl From for PersistenceError { +impl From for ForeignError { fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { Self::InternalError("Unexpected Uniffi callback error".to_string()) } diff --git a/src/receive/uni.rs b/src/receive/uni.rs index 45eeeea..486b6eb 100644 --- a/src/receive/uni.rs +++ b/src/receive/uni.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use super::InputPair; use crate::bitcoin_ffi::{Address, OutPoint, Script, TxOut}; -use crate::error::PersistenceError; +use crate::error::ForeignError; pub use crate::receive::{ Error, ImplementationError, InputContributionError, JsonReply, OutputSubstitutionError, ReplyableError, SelectionError, SerdeJsonError, SessionError, @@ -166,9 +166,9 @@ pub struct RequestResponse { pub client_response: Arc, } -#[uniffi::export] +#[uniffi::export(with_foreign)] pub trait CanBroadcast: Send + Sync { - fn callback(&self, tx: Vec) -> Result; + fn callback(&self, tx: Vec) -> Result; } /// The sender’s original PSBT and optional parameters @@ -207,7 +207,9 @@ impl UncheckedProposal { self.0 .clone() .check_broadcast_suitability(min_fee_rate, |transaction| { - can_broadcast.callback(transaction.to_vec()) + can_broadcast + .callback(transaction.to_vec()) + .map_err(|e| ImplementationError::from(e.to_string())) }) .map(|e| Arc::new(e.into())) } @@ -255,9 +257,9 @@ impl From for MaybeInputsOwned { } } -#[uniffi::export] +#[uniffi::export(with_foreign)] pub trait IsScriptOwned: Send + Sync { - fn callback(&self, script: Vec) -> Result; + fn callback(&self, script: Vec) -> Result; } #[uniffi::export] @@ -269,14 +271,18 @@ impl MaybeInputsOwned { is_owned: Arc, ) -> Result, ReplyableError> { self.0 - .check_inputs_not_owned(|input| is_owned.callback(input.to_vec())) + .check_inputs_not_owned(|input| { + is_owned + .callback(input.to_vec()) + .map_err(|e| ImplementationError::from(e.to_string())) + }) .map(|t| Arc::new(t.into())) } } -#[uniffi::export] +#[uniffi::export(with_foreign)] pub trait IsOutputKnown: Send + Sync { - fn callback(&self, outpoint: OutPoint) -> Result; + fn callback(&self, outpoint: OutPoint) -> Result; } /// Typestate to validate that the Original PSBT has no inputs that have been seen before. @@ -300,7 +306,11 @@ impl MaybeInputsSeen { ) -> Result, ReplyableError> { self.0 .clone() - .check_no_inputs_seen_before(|outpoint| is_known.callback(outpoint.clone())) + .check_no_inputs_seen_before(|outpoint| { + is_known + .callback(outpoint.clone()) + .map_err(|e| ImplementationError::from(e.to_string())) + }) .map(|t| Arc::new(t.into())) } } @@ -327,7 +337,9 @@ impl OutputsUnknown { self.0 .clone() .identify_receiver_outputs(|output_script| { - is_receiver_output.callback(output_script.to_vec()) + is_receiver_output + .callback(output_script.to_vec()) + .map_err(|e| ImplementationError::from(e.to_string())) }) .map(|t| Arc::new(t.into())) } @@ -439,7 +451,11 @@ impl ProvisionalProposal { ) -> Result, ReplyableError> { self.0 .finalize_proposal( - |psbt| process_psbt.callback(psbt.to_string()), + |psbt| { + process_psbt + .callback(psbt.to_string()) + .map_err(|e| ImplementationError::from(e.to_string())) + }, min_feerate_sat_per_vb, max_effective_fee_rate_sat_per_vb, ) @@ -447,9 +463,9 @@ impl ProvisionalProposal { } } -#[uniffi::export] +#[uniffi::export(with_foreign)] pub trait ProcessPsbt: Send + Sync { - fn callback(&self, psbt: String) -> Result; + fn callback(&self, psbt: String) -> Result; } #[derive(Clone, uniffi::Object)] @@ -501,8 +517,8 @@ impl PayjoinProposal { #[uniffi::export(with_foreign)] pub trait ReceiverPersister: Send + Sync { - fn save(&self, receiver: Arc) -> Result, PersistenceError>; - fn load(&self, token: Arc) -> Result, PersistenceError>; + fn save(&self, receiver: Arc) -> Result, ForeignError>; + fn load(&self, token: Arc) -> Result, ForeignError>; } /// Adapter for the ReceiverPersister trait to use the save and load callbacks. @@ -518,7 +534,7 @@ impl CallbackPersisterAdapter { impl payjoin::persist::Persister for CallbackPersisterAdapter { type Token = ReceiverToken; - type Error = PersistenceError; + type Error = ForeignError; fn save( &mut self, diff --git a/src/send/uni.rs b/src/send/uni.rs index 575fbe8..c140cc4 100644 --- a/src/send/uni.rs +++ b/src/send/uni.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::error::PersistenceError; +use crate::error::ForeignError; pub use crate::send::{ BuildSenderError, CreateRequestError, EncapsulationError, ResponseError, SerdeJsonError, }; @@ -269,8 +269,8 @@ impl V2GetContext { #[uniffi::export(with_foreign)] pub trait SenderPersister: Send + Sync { - fn save(&self, sender: Arc) -> Result, PersistenceError>; - fn load(&self, token: Arc) -> Result, PersistenceError>; + fn save(&self, sender: Arc) -> Result, ForeignError>; + fn load(&self, token: Arc) -> Result, ForeignError>; } // The adapter to use the save and load callbacks @@ -287,7 +287,7 @@ impl CallbackPersisterAdapter { // Implement the Persister trait for the adapter impl payjoin::persist::Persister for CallbackPersisterAdapter { type Token = SenderToken; // Define the token type - type Error = PersistenceError; // Define the error type + type Error = ForeignError; // Define the error type fn save(&mut self, sender: payjoin::send::v2::Sender) -> Result { let sender = Sender(super::Sender::from(sender)); diff --git a/src/test_utils.rs b/src/test_utils.rs index b14ac1a..3240f2c 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,19 +1,110 @@ +use std::io; use std::sync::Arc; +use bitcoin_ffi::Psbt; +use bitcoincore_rpc::RpcApi; +use bitcoind::bitcoincore_rpc; +use bitcoind::bitcoincore_rpc::json::AddressType; +use lazy_static::lazy_static; use payjoin_test_utils::{ EXAMPLE_URL, INVALID_PSBT, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT, PARSED_PAYJOIN_PROPOSAL, PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO, PAYJOIN_PROPOSAL, PAYJOIN_PROPOSAL_WITH_SENDER_INFO, QUERY_PARAMS, RECEIVER_INPUT_CONTRIBUTION, }; +use serde_json::Value; +use tokio::runtime::Runtime; use tokio::sync::Mutex; use crate::Url; +lazy_static! { + static ref RUNTIME: Arc> = + Arc::new(std::sync::Mutex::new(Runtime::new().expect("Failed to create Tokio runtime"))); +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct BitcoindEnv { + pub bitcoind: Arc, + pub receiver: Arc, + pub sender: Arc, +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl BitcoindEnv { + pub fn get_receiver(&self) -> Arc { + self.receiver.clone() + } + + pub fn get_sender(&self) -> Arc { + self.sender.clone() + } + + pub fn get_bitcoind(&self) -> Arc { + self.bitcoind.clone() + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct BitcoindInstance { + _inner: bitcoind::BitcoinD, +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct RpcClient { + inner: bitcoincore_rpc::Client, +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl RpcClient { + pub fn call(&self, method: String, params: Vec>) -> Result { + let parsed_params: Vec = params + .into_iter() + .map(|param| { + match param { + Some(p) => serde_json::from_str(&p).unwrap_or(Value::String(p)), + None => Value::Null, + } + }) + .collect(); + + let result = self + .inner + .call::(&method, &parsed_params) + .map_err(|e| FfiError::new(format!("RPC call failed: {e}")))?; + + serde_json::to_string(&result) + .map_err(|e| FfiError::new(format!("Serialization error: {e}"))) + } +} + +#[derive(Debug, thiserror::Error)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] +pub enum FfiError { + #[error("Init error: {0}")] + InitError(String), + #[error("Rpc error: {0}")] + RpcError(String), + #[error("{0}")] + Message(String), +} + +impl FfiError { + pub fn new(msg: impl Into) -> Self { + FfiError::Message(msg.into()) + } +} + #[derive(Debug, thiserror::Error)] #[error(transparent)] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct BoxSendSyncError(#[from] payjoin_test_utils::BoxSendSyncError); +impl From for BoxSendSyncError { + fn from(err: io::Error) -> Self { + payjoin_test_utils::BoxSendSyncError::from(err).into() + } +} + #[cfg_attr(feature = "uniffi", uniffi::export)] pub fn init_tracing() { payjoin_test_utils::init_tracing(); @@ -43,56 +134,82 @@ impl From for payjoin_test_utils::TestServices { #[cfg_attr(feature = "uniffi", uniffi::export)] impl TestServices { #[cfg_attr(feature = "uniffi", uniffi::constructor)] - pub async fn initialize() -> Result { - Ok(payjoin_test_utils::TestServices::initialize().await?.into()) + pub fn initialize() -> Result { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + let service = runtime.block_on(async { + payjoin_test_utils::TestServices::initialize().await.map_err(|e| { + eprintln!("Initialization failed: {e}"); + BoxSendSyncError::from(e) + }) + })?; + Ok(TestServices(Mutex::new(service))) } - pub async fn cert(&self) -> Vec { - self.0.lock().await.cert() + pub fn cert(&self) -> Vec { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime.block_on(async { self.0.lock().await.cert() }) } - pub async fn directory_url(&self) -> Url { - self.0.lock().await.directory_url().into() + pub fn directory_url(&self) -> Url { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime.block_on(async { self.0.lock().await.directory_url().into() }) } - pub async fn take_directory_handle(&self) -> JoinHandle { - JoinHandle(Arc::new(self.0.lock().await.take_directory_handle())) + pub fn take_directory_handle(&self) -> JoinHandle { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime + .block_on(async { JoinHandle(Arc::new(self.0.lock().await.take_directory_handle())) }) } - pub async fn ohttp_relay_url(&self) -> Url { - self.0.lock().await.ohttp_relay_url().into() + pub fn ohttp_relay_url(&self) -> Url { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime.block_on(async { self.0.lock().await.ohttp_relay_url().into() }) } - pub async fn ohttp_gateway_url(&self) -> Url { - self.0.lock().await.ohttp_gateway_url().into() + pub fn ohttp_gateway_url(&self) -> Url { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime.block_on(async { self.0.lock().await.ohttp_gateway_url().into() }) } - pub async fn take_ohttp_relay_handle(&self) -> JoinHandle { - JoinHandle(Arc::new(self.0.lock().await.take_ohttp_relay_handle())) + pub fn take_ohttp_relay_handle(&self) -> JoinHandle { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime + .block_on(async { JoinHandle(Arc::new(self.0.lock().await.take_ohttp_relay_handle())) }) } - pub async fn wait_for_services_ready(&self) -> Result<(), BoxSendSyncError> { - self.0 - .lock() - .await - .wait_for_services_ready() - .await - .map_err(|e| payjoin_test_utils::BoxSendSyncError::from(e).into()) + pub fn wait_for_services_ready(&self) -> Result<(), BoxSendSyncError> { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime.block_on(async { + self.0 + .lock() + .await + .wait_for_services_ready() + .await + .map_err(|e| payjoin_test_utils::BoxSendSyncError::from(e).into()) + }) } - pub async fn fetch_ohttp_keys(&self) -> Result { - self.0.lock().await.fetch_ohttp_keys().await.map_err(Into::into).map(Into::into) + pub fn fetch_ohttp_keys(&self) -> Result { + let runtime = RUNTIME.lock().expect("Lock should not be poisoned"); + runtime.block_on(async { + self.0.lock().await.fetch_ohttp_keys().await.map_err(Into::into).map(Into::into) + }) } } -#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] -#[derive(Debug)] -pub struct Psbt(#[allow(dead_code)] pub(crate) std::sync::Mutex); +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn init_bitcoind_sender_receiver() -> Result, FfiError> { + let (bitcoind, receiver, sender) = payjoin_test_utils::init_bitcoind_sender_receiver( + Some(AddressType::Bech32), + Some(AddressType::Bech32), + ) + .map_err(|e| FfiError::InitError(e.to_string()))?; -impl From for Psbt { - fn from(psbt: payjoin::bitcoin::Psbt) -> Self { - Self(std::sync::Mutex::new(psbt)) - } + Ok(Arc::new(BitcoindEnv { + bitcoind: Arc::new(BitcoindInstance { _inner: bitcoind }), + receiver: Arc::new(RpcClient { inner: receiver }), + sender: Arc::new(RpcClient { inner: sender }), + })) } #[cfg_attr(feature = "uniffi", uniffi::export)]