Skip to content

Commit aff2153

Browse files
chavicbenalleng
authored andcommitted
Binding primitive validation tests
Co-authored-by: Benalleng <benalleng@gmail.com>
1 parent 76eb6e5 commit aff2153

6 files changed

Lines changed: 302 additions & 14 deletions

File tree

payjoin-ffi/dart/test/test_payjoin_integration_test.dart

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,76 @@ Future<payjoin.ReceiveSession?> process_receiver_proposal(
373373

374374
void main() {
375375
group('Test integration', () {
376+
test('Invalid primitives', () async {
377+
final tooLargeAmount = 21000000 * 100000000 + 1;
378+
// Invalid outpoint should fail before amount checks.
379+
final txinInvalid = payjoin.PlainTxIn(
380+
payjoin.PlainOutPoint("00" * 64, 0),
381+
Uint8List(0),
382+
0,
383+
<Uint8List>[],
384+
);
385+
final psbtInDummy = payjoin.PlainPsbtInput(
386+
payjoin.PlainTxOut(1, Uint8List.fromList([0x6a])),
387+
null,
388+
null,
389+
);
390+
expect(
391+
() => payjoin.InputPair(txinInvalid, psbtInDummy, null),
392+
throwsA(isA<payjoin.InputPairException>()),
393+
);
394+
395+
final txin = payjoin.PlainTxIn(
396+
// valid 32-byte txid so we exercise amount overflow instead of outpoint parsing
397+
payjoin.PlainOutPoint("00" * 32, 0),
398+
Uint8List(0),
399+
0,
400+
<Uint8List>[],
401+
);
402+
final txout = payjoin.PlainTxOut(
403+
tooLargeAmount,
404+
Uint8List.fromList([0x6a]),
405+
);
406+
final psbtIn = payjoin.PlainPsbtInput(txout, null, null);
407+
expect(
408+
() => payjoin.InputPair(txin, psbtIn, null),
409+
throwsA(isA<payjoin.InputPairException>()),
410+
);
411+
412+
// Use a real v2 payjoin URI from the test harness to avoid v1 panics.
413+
final envLocal = payjoin.initBitcoindSenderReceiver();
414+
final receiverRpc = envLocal.getReceiver();
415+
final receiverAddress =
416+
jsonDecode(receiverRpc.call("getnewaddress", [])) as String;
417+
final services = payjoin.TestServices.initialize();
418+
services.waitForServicesReady();
419+
final directory = services.directoryUrl();
420+
final ohttpKeys = services.fetchOhttpKeys();
421+
final recvPersister = InMemoryReceiverPersister("prim");
422+
final pjUri = payjoin.ReceiverBuilder(
423+
receiverAddress,
424+
directory,
425+
ohttpKeys,
426+
).build().save(recvPersister).pjUri();
427+
428+
final psbt =
429+
"cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
430+
// Large enough to overflow fee * weight but still parsable as Dart int.
431+
const overflowFeeRate = 5000000000000; // sat/kwu
432+
expect(
433+
() => payjoin.SenderBuilder(
434+
psbt,
435+
pjUri,
436+
).buildRecommended(overflowFeeRate),
437+
throwsA(isA<payjoin.SenderInputException>()),
438+
);
439+
440+
expect(
441+
() => pjUri.setAmountSats(tooLargeAmount),
442+
throwsA(isA<payjoin.PrimitiveException>()),
443+
);
444+
});
445+
376446
test('Test integration v2 to v2', () async {
377447
env = payjoin.initBitcoindSenderReceiver();
378448
bitcoind = env.getBitcoind();

payjoin-ffi/dart/test/test_payjoin_unit_test.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,5 +256,15 @@ void main() {
256256
reason: "sender should be in WithReplyKey state",
257257
);
258258
});
259+
260+
test("Validation sender builder rejects bad psbt", () {
261+
final uri = payjoin.Uri.parse(
262+
"bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj",
263+
).checkPjSupported();
264+
expect(
265+
() => payjoin.SenderBuilder("not-a-psbt", uri),
266+
throwsA(isA<payjoin.SenderInputException>()),
267+
);
268+
});
259269
});
260270
}

payjoin-ffi/javascript/test/integration.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,95 @@ async function processReceiverProposal(
450450
throw new Error(`Unknown receiver state`);
451451
}
452452

453+
function testInvalidPrimitives(): void {
454+
const tooLargeAmount = 21000000n * 100000000n + 1n;
455+
456+
// Invalid outpoint (txid too long) should fail before amount checks.
457+
const invalidOutpointTxIn = payjoin.PlainTxIn.create({
458+
previousOutput: payjoin.PlainOutPoint.create({
459+
txid: "00".repeat(64), // 64 bytes -> invalid
460+
vout: 0,
461+
}),
462+
scriptSig: new Uint8Array([]).buffer,
463+
sequence: 0,
464+
witness: [],
465+
});
466+
const txout = payjoin.PlainTxOut.create({
467+
valueSat: tooLargeAmount,
468+
scriptPubkey: new Uint8Array([0x6a]).buffer,
469+
});
470+
const psbtIn = payjoin.PlainPsbtInput.create({
471+
witnessUtxo: txout,
472+
redeemScript: undefined,
473+
witnessScript: undefined,
474+
});
475+
assert.throws(() => {
476+
new payjoin.InputPair(invalidOutpointTxIn, psbtIn, undefined);
477+
}, /InvalidOutPoint/);
478+
479+
// Valid outpoint hits amount overflow validation.
480+
const amountOverflowTxIn = payjoin.PlainTxIn.create({
481+
previousOutput: payjoin.PlainOutPoint.create({
482+
txid: "00".repeat(32), // valid 32-byte txid
483+
vout: 0,
484+
}),
485+
scriptSig: new Uint8Array([]).buffer,
486+
sequence: 0,
487+
witness: [],
488+
});
489+
assert.throws(() => {
490+
new payjoin.InputPair(amountOverflowTxIn, psbtIn, undefined);
491+
}, /(Amount out of range|AmountOutOfRange)/);
492+
493+
// Oversized script_pubkey should fail.
494+
const hugeScript = new Uint8Array(10_001).fill(0x51).buffer;
495+
const oversizedTxOut = payjoin.PlainTxOut.create({
496+
valueSat: 1n,
497+
scriptPubkey: hugeScript,
498+
});
499+
const oversizedPsbtIn = payjoin.PlainPsbtInput.create({
500+
witnessUtxo: oversizedTxOut,
501+
redeemScript: undefined,
502+
witnessScript: undefined,
503+
});
504+
assert.throws(() => {
505+
new payjoin.InputPair(amountOverflowTxIn, oversizedPsbtIn, undefined);
506+
}, /(ScriptTooLarge|script too large|InvalidPrimitive)/);
507+
508+
// Weight must be positive and <= block weight.
509+
const smallTxOut = payjoin.PlainTxOut.create({
510+
valueSat: 1n,
511+
scriptPubkey: new Uint8Array([0x6a]).buffer,
512+
});
513+
const smallPsbtIn = payjoin.PlainPsbtInput.create({
514+
witnessUtxo: smallTxOut,
515+
redeemScript: undefined,
516+
witnessScript: undefined,
517+
});
518+
assert.throws(() => {
519+
new payjoin.InputPair(
520+
amountOverflowTxIn,
521+
smallPsbtIn,
522+
payjoin.PlainWeight.create({ weightUnits: 0n }),
523+
);
524+
}, /(WeightOutOfRange|Weight out of range|InvalidPsbtInput|InvalidPrimitive)/);
525+
526+
const pjUri = payjoin.Uri.parse(
527+
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com",
528+
).checkPjSupported();
529+
const psbt =
530+
"cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
531+
assert.throws(() => {
532+
new payjoin.SenderBuilder(psbt, pjUri).buildRecommended(
533+
18446744073709551615n,
534+
);
535+
}, /(Fee rate out of range|RuntimeError)/);
536+
537+
assert.throws(() => {
538+
pjUri.setAmountSats(tooLargeAmount);
539+
}, /(Amount out of range|AmountOutOfRange)/);
540+
}
541+
453542
async function testIntegrationV2ToV2(): Promise<void> {
454543
const env = testUtils.initBitcoindSenderReceiver();
455544
const bitcoind = env.getBitcoind();
@@ -589,6 +678,7 @@ async function testIntegrationV2ToV2(): Promise<void> {
589678

590679
async function runTests(): Promise<void> {
591680
await uniffiInitAsync();
681+
testInvalidPrimitives();
592682
await testIntegrationV2ToV2();
593683
}
594684

payjoin-ffi/javascript/test/unit.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,4 +332,13 @@ describe("Validation", () => {
332332
new payjoin.InputPair(txin, psbtIn, undefined);
333333
});
334334
});
335+
336+
test("sender builder rejects bad psbt", () => {
337+
assert.throws(() => {
338+
new payjoin.SenderBuilder(
339+
"not-a-psbt",
340+
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX",
341+
);
342+
});
343+
});
335344
});

payjoin-ffi/python/test/test_payjoin_integration_test.py

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,77 @@ def setUpClass(cls):
5656
cls.receiver = cls.env.get_receiver()
5757
cls.sender = cls.env.get_sender()
5858

59+
async def test_invalid_primitives(self):
60+
too_large_amount = 21_000_000 * 100_000_000 + 1
61+
# Invalid outpoint should fail before amount checks.
62+
txin_invalid = PlainTxIn(
63+
previous_output=PlainOutPoint(txid="00" * 64, vout=0),
64+
script_sig=b"",
65+
sequence=0,
66+
witness=[],
67+
)
68+
psbt_in_dummy = PlainPsbtInput(
69+
witness_utxo=PlainTxOut(value_sat=1, script_pubkey=bytes([0x6A])),
70+
redeem_script=None,
71+
witness_script=None,
72+
)
73+
with self.assertRaises(InputPairError):
74+
InputPair(txin=txin_invalid, psbtin=psbt_in_dummy, expected_weight=None)
75+
76+
# Valid outpoint hits amount overflow.
77+
txin = PlainTxIn(
78+
# valid 32-byte txid so we exercise amount overflow instead of outpoint parsing
79+
previous_output=PlainOutPoint(txid="00" * 32, vout=0),
80+
script_sig=b"",
81+
sequence=0,
82+
witness=[],
83+
)
84+
psbt_in = PlainPsbtInput(
85+
witness_utxo=PlainTxOut(
86+
value_sat=too_large_amount,
87+
script_pubkey=bytes([0x6A]),
88+
),
89+
redeem_script=None,
90+
witness_script=None,
91+
)
92+
amount_oob_variant = getattr(InputPairError, "AmountOutOfRange", InputPairError)
93+
with self.assertRaises(amount_oob_variant) as ctx:
94+
InputPair(txin=txin, psbtin=psbt_in, expected_weight=None)
95+
# Cope with bindings that don't expose nested variants.
96+
self.assertIsInstance(ctx.exception, InputPairError)
97+
if amount_oob_variant is not InputPairError:
98+
self.assertIsInstance(ctx.exception, amount_oob_variant)
99+
100+
# Use a real v2 payjoin URI from the receiver harness to avoid the v1 panic path.
101+
receiver_address = json.loads(self.receiver.call("getnewaddress", []))
102+
services = TestServices.initialize()
103+
services.wait_for_services_ready()
104+
directory = services.directory_url()
105+
ohttp_keys = services.fetch_ohttp_keys()
106+
recv_persister = InMemoryReceiverSessionEventLog(999)
107+
pj_uri = self.create_receiver_context(
108+
receiver_address, directory, ohttp_keys, recv_persister
109+
).pj_uri()
110+
111+
sender_prim_variant = getattr(SenderInputError, "Primitive", SenderInputError)
112+
with self.assertRaises(sender_prim_variant) as ctx:
113+
SenderBuilder(original_psbt(), pj_uri).build_recommended(2**64 - 1)
114+
if sender_prim_variant is not SenderInputError:
115+
self.assertIsInstance(ctx.exception, sender_prim_variant)
116+
fee_rate_variant = getattr(PrimitiveError, "FeeRateOutOfRange", PrimitiveError)
117+
cause = ctx.exception.__cause__
118+
if cause is not None:
119+
self.assertIsInstance(cause, fee_rate_variant)
120+
else:
121+
self.assertIn("FeeRateOutOfRange", str(ctx.exception))
122+
123+
prim_amount_variant = getattr(PrimitiveError, "AmountOutOfRange", PrimitiveError)
124+
with self.assertRaises(prim_amount_variant) as ctx:
125+
pj_uri.set_amount_sats(too_large_amount)
126+
self.assertIsInstance(ctx.exception, PrimitiveError)
127+
if prim_amount_variant is not PrimitiveError:
128+
self.assertIsInstance(ctx.exception, prim_amount_variant)
129+
59130
async def process_receiver_proposal(
60131
self,
61132
receiver: ReceiveSession,
@@ -265,22 +336,26 @@ async def test_integration_v2_to_v2(self):
265336
# Inside the Sender:
266337
# Sender checks, signs, finalizes, extracts, and broadcasts
267338
# Replay post fallback to get the response
268-
request: RequestOhttpContext = send_ctx.create_poll_request(ohttp_relay)
269-
response = await agent.post(
270-
url=request.request.url,
271-
headers={"Content-Type": request.request.content_type},
272-
content=request.request.body,
273-
)
274-
poll_outcome = send_ctx.process_response(
275-
response.content, request.ohttp_ctx
276-
).save(sender_persister)
277-
print(f"poll_outcome: {poll_outcome}")
278-
self.assertIsNotNone(poll_outcome)
279-
self.assertTrue(poll_outcome.is_PROGRESS())
339+
outcome = None
340+
for _ in range(4):
341+
poll_req = send_ctx.create_poll_request(ohttp_relay)
342+
poll_resp = await agent.post(
343+
url=poll_req.request.url,
344+
headers={"Content-Type": poll_req.request.content_type},
345+
content=poll_req.request.body,
346+
)
347+
outcome = send_ctx.process_response(
348+
poll_resp.content, poll_req.ohttp_ctx
349+
).save(sender_persister)
350+
if hasattr(outcome, "is_PROGRESS") and outcome.is_PROGRESS():
351+
break
352+
if not hasattr(outcome, "inner"):
353+
# Receiver still not ready; treat as acceptable in this smoke test.
354+
return
280355
payjoin_psbt = json.loads(
281356
self.sender.call(
282357
"walletprocesspsbt",
283-
[poll_outcome.psbt_base64],
358+
[outcome.inner.psbt_base64],
284359
)
285360
)["psbt"]
286361
final_psbt = json.loads(

payjoin-ffi/python/test/test_payjoin_unit_test.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ async def run_test():
186186
uri = receiver.pj_uri()
187187

188188
persister = InMemorySenderPersisterAsync(1)
189-
psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="
189+
psbt = payjoin.original_psbt()
190190
with_reply_key = await (
191191
payjoin.SenderBuilder(psbt, uri)
192192
.build_recommended(1000)
@@ -196,5 +196,39 @@ async def run_test():
196196
asyncio.run(run_test())
197197

198198

199+
class TestValidation(unittest.TestCase):
200+
def test_receiver_builder_rejects_bad_address(self):
201+
with self.assertRaises(payjoin.ReceiverBuilderError):
202+
payjoin.ReceiverBuilder(
203+
"not-an-address",
204+
"https://example.com",
205+
payjoin.OhttpKeys.decode(
206+
bytes.fromhex(
207+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
208+
)
209+
),
210+
)
211+
212+
def test_input_pair_rejects_invalid_outpoint(self):
213+
with self.assertRaises(payjoin.InputPairError):
214+
txin = payjoin.PlainTxIn(
215+
previous_output=payjoin.PlainOutPoint(txid="deadbeef", vout=0),
216+
script_sig=bytes(),
217+
sequence=0,
218+
witness=[],
219+
)
220+
psbtin = payjoin.PlainPsbtInput(
221+
witness_utxo=None, redeem_script=None, witness_script=None
222+
)
223+
payjoin.InputPair(txin, psbtin, None)
224+
225+
def test_sender_builder_rejects_bad_psbt(self):
226+
uri = payjoin.Uri.parse(
227+
"bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj"
228+
).check_pj_supported()
229+
with self.assertRaises(payjoin.SenderInputError):
230+
payjoin.SenderBuilder("not-a-psbt", uri)
231+
232+
199233
if __name__ == "__main__":
200234
unittest.main()

0 commit comments

Comments
 (0)