Skip to content

Commit a44a282

Browse files
committed
Validate primitive inputs and add integration checks
1 parent 6dbb0db commit a44a282

File tree

11 files changed

+213
-72
lines changed

11 files changed

+213
-72
lines changed

payjoin-ffi/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ serde = { version = "1.0.219", features = ["derive"] }
3131
serde_json = "1.0.142"
3232
thiserror = "2.0.14"
3333
tokio = { version = "1.47.1", features = ["full"], optional = true }
34-
uniffi = { version = "0.30.0", features = ["cli"] }
34+
uniffi = { version = "0.30.0", features = [
35+
"cli",
36+
"wasm-unstable-single-threaded",
37+
] }
3538
uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "5bdcc79", optional = true }
3639
url = "2.5.4"
3740

payjoin-ffi/dart/test/test_payjoin_integration_test.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,41 @@ 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+
final txin = payjoin.PlainTxIn(
379+
payjoin.PlainOutPoint("00" * 64, 0),
380+
Uint8List(0),
381+
0,
382+
<Uint8List>[],
383+
);
384+
final txout = payjoin.PlainTxOut(
385+
tooLargeAmount,
386+
Uint8List.fromList([0x6a]),
387+
);
388+
final psbtIn = payjoin.PlainPsbtInput(txout, null, null);
389+
expect(
390+
() => payjoin.InputPair(txin, psbtIn, null),
391+
throwsA(isA<payjoin.InputPairException>()),
392+
);
393+
394+
final pjUri = payjoin.Uri.parse(
395+
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com",
396+
).checkPjSupported();
397+
final psbt =
398+
"cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
399+
final maxU64 = int.parse("18446744073709551615");
400+
expect(
401+
() => payjoin.SenderBuilder(psbt, pjUri).buildRecommended(maxU64),
402+
throwsA(isA<payjoin.SenderInputException>()),
403+
);
404+
405+
expect(
406+
() => pjUri.setAmountSats(tooLargeAmount),
407+
throwsA(isA<payjoin.PrimitiveException>()),
408+
);
409+
});
410+
376411
test('Test integration v2 to v2', () async {
377412
env = payjoin.initBitcoindSenderReceiver();
378413
bitcoind = env.getBitcoind();

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

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

453+
function testInvalidPrimitives(): void {
454+
const tooLargeAmount = 21000000n * 100000000n + 1n;
455+
const txin = payjoin.PlainTxIn.create({
456+
previousOutput: payjoin.PlainOutPoint.create({
457+
txid: "00".repeat(64),
458+
vout: 0,
459+
}),
460+
scriptSig: new Uint8Array([]).buffer,
461+
sequence: 0,
462+
witness: [],
463+
});
464+
const txout = payjoin.PlainTxOut.create({
465+
valueSat: tooLargeAmount,
466+
scriptPubkey: new Uint8Array([0x6a]).buffer,
467+
});
468+
const psbtIn = payjoin.PlainPsbtInput.create({
469+
witnessUtxo: txout,
470+
redeemScript: undefined,
471+
witnessScript: undefined,
472+
});
473+
assert.throws(() => {
474+
new payjoin.InputPair(txin, psbtIn, undefined);
475+
}, /Amount out of range/);
476+
477+
const pjUri = payjoin.Uri.parse(
478+
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com",
479+
).checkPjSupported();
480+
const psbt =
481+
"cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
482+
assert.throws(() => {
483+
new payjoin.SenderBuilder(psbt, pjUri).buildRecommended(
484+
18446744073709551615n,
485+
);
486+
}, /Fee rate out of range/);
487+
488+
assert.throws(() => {
489+
pjUri.setAmountSats(tooLargeAmount);
490+
}, /Amount out of range/);
491+
}
492+
453493
async function testIntegrationV2ToV2(): Promise<void> {
454494
const env = testUtils.initBitcoindSenderReceiver();
455495
const bitcoind = env.getBitcoind();
@@ -589,6 +629,7 @@ async function testIntegrationV2ToV2(): Promise<void> {
589629

590630
async function runTests(): Promise<void> {
591631
await uniffiInitAsync();
632+
testInvalidPrimitives();
592633
await testIntegrationV2ToV2();
593634
}
594635

payjoin-ffi/python/test/test_payjoin_integration_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,37 @@ def setUpClass(cls):
5757
cls.receiver = cls.env.get_receiver()
5858
cls.sender = cls.env.get_sender()
5959

60+
async def test_invalid_primitives(self):
61+
too_large_amount = 21_000_000 * 100_000_000 + 1
62+
txin = PlainTxIn(
63+
previous_output=PlainOutPoint(txid="00" * 64, vout=0),
64+
script_sig=b"",
65+
sequence=0,
66+
witness=[],
67+
)
68+
psbt_in = PlainPsbtInput(
69+
witness_utxo=PlainTxOut(
70+
value_sat=too_large_amount,
71+
script_pubkey=bytes([0x6A]),
72+
),
73+
redeem_script=None,
74+
witness_script=None,
75+
)
76+
with self.assertRaises(InputPairError) as ctx:
77+
InputPair(txin=txin, psbtin=psbt_in, expected_weight=None)
78+
self.assertIn("Amount out of range", str(ctx.exception))
79+
80+
pj_uri = Uri.parse(
81+
"bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com"
82+
).check_pj_supported()
83+
with self.assertRaises(SenderInputError) as ctx:
84+
SenderBuilder(original_psbt(), pj_uri).build_recommended(2**64 - 1)
85+
self.assertIn("Fee rate out of range", str(ctx.exception))
86+
87+
with self.assertRaises(PrimitiveError) as ctx:
88+
pj_uri.set_amount_sats(too_large_amount)
89+
self.assertIn("Amount out of range", str(ctx.exception))
90+
6091
async def process_receiver_proposal(
6192
self,
6293
receiver: ReceiveSession,

payjoin-ffi/src/error.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ pub enum PrimitiveError {
2828
#[error("{field} script is empty")]
2929
ScriptEmpty { field: String },
3030
#[error("{field} script too large: {len} bytes (max {max})")]
31-
ScriptTooLarge { field: String, len: usize, max: usize },
31+
ScriptTooLarge { field: String, len: u64, max: u64 },
3232
#[error("Witness stack has {count} items (max {max})")]
33-
WitnessItemsTooMany { count: usize, max: usize },
33+
WitnessItemsTooMany { count: u64, max: u64 },
3434
#[error("Witness item {index} too large: {len} bytes (max {max})")]
35-
WitnessItemTooLarge { index: usize, len: usize, max: usize },
35+
WitnessItemTooLarge { index: u64, len: u64, max: u64 },
3636
#[error("Witness stack too large: {len} bytes (max {max})")]
37-
WitnessTooLarge { len: usize, max: usize },
37+
WitnessTooLarge { len: u64, max: u64 },
3838
#[error("Weight out of range: {weight_units} wu (max {max_wu})")]
3939
WeightOutOfRange { weight_units: u64, max_wu: u64 },
4040
#[error("Fee rate out of range: {value} {unit}")]

payjoin-ffi/src/receive/error.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,23 +168,28 @@ impl From<ProtocolError> for JsonReply {
168168
#[error(transparent)]
169169
pub struct SessionError(#[from] receive::v2::SessionError);
170170

171+
/// Protocol error raised during output substitution.
172+
#[derive(Debug, thiserror::Error, uniffi::Object)]
173+
#[error(transparent)]
174+
pub struct OutputSubstitutionProtocolError(#[from] receive::OutputSubstitutionError);
175+
171176
/// Error that may occur when output substitution fails.
172177
#[derive(Debug, thiserror::Error, uniffi::Error)]
173178
pub enum OutputSubstitutionError {
174179
#[error(transparent)]
175-
Protocol(Arc<receive::OutputSubstitutionError>),
180+
Protocol(Arc<OutputSubstitutionProtocolError>),
176181
#[error(transparent)]
177-
Primitive(Arc<PrimitiveError>),
182+
Primitive(PrimitiveError),
178183
}
179184

180185
impl From<receive::OutputSubstitutionError> for OutputSubstitutionError {
181186
fn from(value: receive::OutputSubstitutionError) -> Self {
182-
OutputSubstitutionError::Protocol(Arc::new(value))
187+
OutputSubstitutionError::Protocol(Arc::new(value.into()))
183188
}
184189
}
185190

186191
impl From<PrimitiveError> for OutputSubstitutionError {
187-
fn from(value: PrimitiveError) -> Self { OutputSubstitutionError::Primitive(Arc::new(value)) }
192+
fn from(value: PrimitiveError) -> Self { OutputSubstitutionError::Primitive(value) }
188193
}
189194

190195
/// Error that may occur when coin selection fails.
@@ -213,7 +218,7 @@ pub enum InputPairError {
213218
InvalidPsbtInput(Arc<PsbtInputError>),
214219
/// Primitive input failed validation in the FFI layer.
215220
#[error("Invalid primitive input: {0}")]
216-
InvalidPrimitive(Arc<PrimitiveError>),
221+
InvalidPrimitive(PrimitiveError),
217222
}
218223

219224
impl InputPairError {
@@ -223,9 +228,7 @@ impl InputPairError {
223228
}
224229

225230
impl From<PrimitiveError> for InputPairError {
226-
fn from(value: PrimitiveError) -> Self {
227-
InputPairError::InvalidPrimitive(Arc::new(value))
228-
}
231+
fn from(value: PrimitiveError) -> Self { InputPairError::InvalidPrimitive(value) }
229232
}
230233

231234
/// Error that may occur when a receiver event log is replayed

0 commit comments

Comments
 (0)