Skip to content
Draft
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
5 changes: 4 additions & 1 deletion payjoin-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
thiserror = "2.0.14"
tokio = { version = "1.47.1", features = ["full"], optional = true }
uniffi = { version = "0.30.0", features = ["cli"] }
uniffi = { version = "0.30.0", features = [
"cli",
"wasm-unstable-single-threaded",
] }
uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "5bdcc79", optional = true }
url = "2.5.4"

Expand Down
35 changes: 35 additions & 0 deletions payjoin-ffi/dart/test/test_payjoin_integration_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,41 @@ Future<payjoin.ReceiveSession?> process_receiver_proposal(

void main() {
group('Test integration', () {
test('Invalid primitives', () async {
final tooLargeAmount = 21000000 * 100000000 + 1;
final txin = payjoin.PlainTxIn(
payjoin.PlainOutPoint("00" * 64, 0),
Uint8List(0),
0,
<Uint8List>[],
);
final txout = payjoin.PlainTxOut(
tooLargeAmount,
Uint8List.fromList([0x6a]),
);
final psbtIn = payjoin.PlainPsbtInput(txout, null, null);
expect(
() => payjoin.InputPair(txin, psbtIn, null),
throwsA(isA<payjoin.InputPairException>()),
);

final pjUri = payjoin.Uri.parse(
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com",
).checkPjSupported();
final psbt =
"cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
final maxU64 = int.parse("18446744073709551615");
expect(
() => payjoin.SenderBuilder(psbt, pjUri).buildRecommended(maxU64),
throwsA(isA<payjoin.SenderInputException>()),
);

expect(
() => pjUri.setAmountSats(tooLargeAmount),
throwsA(isA<payjoin.PrimitiveException>()),
);
});

test('Test integration v2 to v2', () async {
env = payjoin.initBitcoindSenderReceiver();
bitcoind = env.getBitcoind();
Expand Down
10 changes: 10 additions & 0 deletions payjoin-ffi/dart/test/test_payjoin_unit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,5 +152,15 @@ void main() {
reason: "persistence should return a reply key",
);
});

test("Validation sender builder rejects bad psbt", () {
final uri = payjoin.Uri.parse(
"bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj",
).checkPjSupported();
expect(
() => payjoin.SenderBuilder("not-a-psbt", uri),
throwsA(isA<payjoin.SenderInputException>()),
);
});
});
}
41 changes: 41 additions & 0 deletions payjoin-ffi/javascript/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,46 @@ async function processReceiverProposal(
throw new Error(`Unknown receiver state`);
}

function testInvalidPrimitives(): void {
const tooLargeAmount = 21000000n * 100000000n + 1n;
const txin = payjoin.PlainTxIn.create({
previousOutput: payjoin.PlainOutPoint.create({
txid: "00".repeat(64),
vout: 0,
}),
scriptSig: new Uint8Array([]).buffer,
sequence: 0,
witness: [],
});
const txout = payjoin.PlainTxOut.create({
valueSat: tooLargeAmount,
scriptPubkey: new Uint8Array([0x6a]).buffer,
});
const psbtIn = payjoin.PlainPsbtInput.create({
witnessUtxo: txout,
redeemScript: undefined,
witnessScript: undefined,
});
assert.throws(() => {
new payjoin.InputPair(txin, psbtIn, undefined);
}, /Amount out of range/);

const pjUri = payjoin.Uri.parse(
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com",
).checkPjSupported();
const psbt =
"cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
assert.throws(() => {
new payjoin.SenderBuilder(psbt, pjUri).buildRecommended(
18446744073709551615n,
);
}, /Fee rate out of range/);

assert.throws(() => {
pjUri.setAmountSats(tooLargeAmount);
}, /Amount out of range/);
}

async function testIntegrationV2ToV2(): Promise<void> {
const env = testUtils.initBitcoindSenderReceiver();
const bitcoind = env.getBitcoind();
Expand Down Expand Up @@ -589,6 +629,7 @@ async function testIntegrationV2ToV2(): Promise<void> {

async function runTests(): Promise<void> {
await uniffiInitAsync();
testInvalidPrimitives();
await testIntegrationV2ToV2();
}

Expand Down
15 changes: 15 additions & 0 deletions payjoin-ffi/javascript/test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ describe("Validation", () => {
});
});

test("sender builder rejects bad psbt", () => {
assert.throws(() => {
new payjoin.SenderBuilder("not-a-psbt", payjoin.exampleUrl());
});
});

test("input pair rejects invalid outpoint", () => {
assert.throws(() => {
const txin = payjoin.PlainTxIn.create({
Expand All @@ -214,4 +220,13 @@ describe("Validation", () => {
new payjoin.InputPair(txin, psbtIn, undefined);
});
});

test("sender builder rejects bad psbt", () => {
assert.throws(() => {
new payjoin.SenderBuilder(
"not-a-psbt",
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX",
);
});
});
});
132 changes: 95 additions & 37 deletions payjoin-ffi/python/test/test_payjoin_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,11 @@
import httpx
import json

from decimal import Decimal
from payjoin import *
from typing import Optional
import unittest

try:
import payjoin.bitcoin as bitcoinffi
except ImportError:
bitcoinffi = None
raise unittest.SkipTest("bitcoin_ffi helpers are not available in this binding")

# 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(
Expand Down Expand Up @@ -62,6 +57,37 @@ def setUpClass(cls):
cls.receiver = cls.env.get_receiver()
cls.sender = cls.env.get_sender()

async def test_invalid_primitives(self):
too_large_amount = 21_000_000 * 100_000_000 + 1
txin = PlainTxIn(
previous_output=PlainOutPoint(txid="00" * 64, vout=0),
script_sig=b"",
sequence=0,
witness=[],
)
psbt_in = PlainPsbtInput(
witness_utxo=PlainTxOut(
value_sat=too_large_amount,
script_pubkey=bytes([0x6A]),
),
redeem_script=None,
witness_script=None,
)
with self.assertRaises(InputPairError) as ctx:
InputPair(txin=txin, psbtin=psbt_in, expected_weight=None)
self.assertIn("Amount out of range", str(ctx.exception))

pj_uri = Uri.parse(
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com"
).check_pj_supported()
with self.assertRaises(SenderInputError) as ctx:
SenderBuilder(original_psbt(), pj_uri).build_recommended(2**64 - 1)
self.assertIn("Fee rate out of range", str(ctx.exception))

with self.assertRaises(PrimitiveError) as ctx:
pj_uri.set_amount_sats(too_large_amount)
self.assertIn("Amount out of range", str(ctx.exception))

async def process_receiver_proposal(
self,
receiver: ReceiveSession,
Expand Down Expand Up @@ -101,7 +127,7 @@ async def process_receiver_proposal(

def create_receiver_context(
self,
receiver_address: bitcoinffi.Address,
receiver_address: str,
directory: str,
ohttp_keys: OhttpKeys,
recv_persister: InMemoryReceiverSessionEventLog,
Expand Down Expand Up @@ -207,10 +233,7 @@ async def process_provisional_proposal(

async def test_integration_v2_to_v2(self):
try:
receiver_address = bitcoinffi.Address(
json.loads(self.receiver.call("getnewaddress", [])),
bitcoinffi.Network.REGTEST,
)
receiver_address = json.loads(self.receiver.call("getnewaddress", []))
init_tracing()
services = TestServices.initialize()

Expand Down Expand Up @@ -295,19 +318,26 @@ async def test_integration_v2_to_v2(self):
[checked_payjoin_proposal_psbt.serialize_base64()],
)
)["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())]
finalized = json.loads(
self.sender.call("finalizepsbt", [payjoin_psbt, json.dumps(True)])
)
payjoin_tx_hex = finalized["hex"]
txid = json.loads(
self.sender.call("sendrawtransaction", [json.dumps(payjoin_tx_hex)])
)
decoded_tx = json.loads(
self.sender.call("decoderawtransaction", [json.dumps(payjoin_tx_hex)])
)

# Check resulting transaction and balances
network_fees = bitcoinffi.Psbt.deserialize_base64(final_psbt).fee().to_btc()
mempool_entry = json.loads(
self.sender.call("getmempoolentry", [json.dumps(txid)])
)
fees = mempool_entry.get("fees", {})
network_fees = fees.get("base", mempool_entry.get("fee", 0))
# 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(len(decoded_tx["vin"]), 2)
self.assertEqual(len(decoded_tx["vout"]), 1)
self.assertEqual(
float(
json.loads(self.receiver.call("getbalances", []))["mine"][
Expand All @@ -323,7 +353,7 @@ async def test_integration_v2_to_v2(self):
raise


def build_sweep_psbt(sender: RpcClient, pj_uri: PjUri) -> bitcoinffi.Psbt:
def build_sweep_psbt(sender: RpcClient, pj_uri: PjUri) -> str:
outputs = {}
outputs[pj_uri.address()] = 50
psbt = json.loads(
Expand Down Expand Up @@ -355,24 +385,28 @@ def get_inputs(rpc_connection: RpcClient) -> 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
value_sat = int(Decimal(str(prev_out["value"])) * Decimal("100000000"))
txin = PlainTxIn(
previous_output=PlainOutPoint(txid=utxo["txid"], vout=utxo["vout"]),
script_sig=b"",
sequence=0,
witness=[],
)
tx_out = PlainTxOut(
value_sat=value_sat,
script_pubkey=bytes.fromhex(prev_out["scriptPubKey"]["hex"]),
)
psbt_in = PlainPsbtInput(
witness_utxo=tx_out,
redeem_script=None,
witness_script=None,
)
inputs.append(InputPair(txin=txin, psbtin=psbt_in, expected_weight=None))

Expand Down Expand Up @@ -402,12 +436,36 @@ def __init__(self, connection: RpcClient):

def callback(self, script):
try:
address = bitcoinffi.Address.from_script(
bitcoinffi.Script(script), bitcoinffi.Network.REGTEST
script_hex = bytes(script).hex()
decoded_script = json.loads(
self.connection.call("decodescript", [json.dumps(script_hex)])
)
return json.loads(self.connection.call("getaddressinfo", [str(address)]))[
"ismine"
]

candidates = []
address = decoded_script.get("address")
if isinstance(address, str):
candidates.append(address)
addresses = decoded_script.get("addresses")
if isinstance(addresses, list):
candidates.extend([addr for addr in addresses if isinstance(addr, str)])
segwit = decoded_script.get("segwit")
if isinstance(segwit, dict):
segwit_address = segwit.get("address")
if isinstance(segwit_address, str):
candidates.append(segwit_address)
segwit_addresses = segwit.get("addresses")
if isinstance(segwit_addresses, list):
candidates.extend(
[addr for addr in segwit_addresses if isinstance(addr, str)]
)

for addr in candidates:
info = json.loads(
self.connection.call("getaddressinfo", [json.dumps(addr)])
)
if info.get("ismine") is True:
return True
return False
except Exception as e:
print(f"An error occurred: {e}")
return None
Expand Down
34 changes: 34 additions & 0 deletions payjoin-ffi/python/test/test_payjoin_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,39 @@ def test_sender_persistence(self):
)


class TestValidation(unittest.TestCase):
def test_receiver_builder_rejects_bad_address(self):
with self.assertRaises(payjoin.ReceiverBuilderError):
payjoin.ReceiverBuilder(
"not-an-address",
"https://example.com",
payjoin.OhttpKeys.decode(
bytes.fromhex(
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
)
),
)

def test_input_pair_rejects_invalid_outpoint(self):
with self.assertRaises(payjoin.InputPairError):
txin = payjoin.PlainTxIn(
previous_output=payjoin.PlainOutPoint(txid="deadbeef", vout=0),
script_sig=bytes(),
sequence=0,
witness=[],
)
psbtin = payjoin.PlainPsbtInput(
witness_utxo=None, redeem_script=None, witness_script=None
)
payjoin.InputPair(txin, psbtin, None)

def test_sender_builder_rejects_bad_psbt(self):
uri = payjoin.Uri.parse(
"bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj"
).check_pj_supported()
with self.assertRaises(payjoin.SenderInputError):
payjoin.SenderBuilder("not-a-psbt", uri)


if __name__ == "__main__":
unittest.main()
Loading
Loading