Skip to content
Open
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
53 changes: 53 additions & 0 deletions payjoin-ffi/csharp/UnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,59 @@ public async Task SenderPersistenceAsync()
}
}

public class CancelTests
{
private static readonly byte[] OhttpKeysData = new byte[]
{
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
0x00, 0x01, 0x00, 0x03,
};

[Fact]
public void ReceiverCancelFromInitialized()
{
var persister = new InMemoryReceiverPersister();
var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);

var initialized = new ReceiverBuilder(address, "https://example.com", ohttpKeys)
.Build()
.Save(persister);
var cancelTransition = initialized.Cancel();
var fallbackTx = cancelTransition.Save(persister);
Assert.Null(fallbackTx);

var result = PayjoinMethods.ReplayReceiverEventLog(persister);
var state = result.State();
Assert.IsType<ReceiveSession.Closed>(state);
}

[Fact]
public async Task ReceiverCancelFromInitializedAsync()
{
var persister = new InMemoryReceiverPersisterAsync();
var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);

var initialized = await new ReceiverBuilder(address, "https://example.com", ohttpKeys)
.Build()
.SaveAsync(persister);
var cancelTransition = initialized.Cancel();
var fallbackTx = await cancelTransition.SaveAsync(persister);
Assert.Null(fallbackTx);

var result = await PayjoinMethods.ReplayReceiverEventLogAsync(persister);
var state = result.State();
Assert.IsType<ReceiveSession.Closed>(state);
}
}

public class ValidationTests
{
private static readonly byte[] OhttpKeysData = new byte[]
Expand Down
52 changes: 52 additions & 0 deletions payjoin-ffi/dart/test/test_payjoin_unit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,58 @@ void main() {
});
});

group("Test Receiver Cancel", () {
test("Test receiver cancel from initialized", () {
var persister = InMemoryReceiverPersister("1");
var initialized = payjoin.ReceiverBuilder(
address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
directory: "https://example.com",
ohttpKeys: payjoin.OhttpKeys.decode(
bytes: Uint8List.fromList(
hex.decode(
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003",
),
),
),
).build().save(persister: persister);
var cancelTransition = initialized.cancel();
var fallbackTx = cancelTransition.save(persister: persister);
expect(fallbackTx, isNull);
final result = payjoin.replayReceiverEventLog(persister: persister);
expect(
result.state(),
isA<payjoin.ClosedReceiveSession>(),
reason: "receiver should be in Closed state after cancel",
);
});

test("Test receiver cancel async from initialized", () async {
var persister = InMemoryReceiverPersisterAsync("1");
var initialized = await payjoin.ReceiverBuilder(
address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
directory: "https://example.com",
ohttpKeys: payjoin.OhttpKeys.decode(
bytes: Uint8List.fromList(
hex.decode(
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003",
),
),
),
).build().saveAsync(persister: persister);
var cancelTransition = initialized.cancel();
var fallbackTx = await cancelTransition.saveAsync(persister: persister);
expect(fallbackTx, isNull);
final result = await payjoin.replayReceiverEventLogAsync(
persister: persister,
);
expect(
result.state(),
isA<payjoin.ClosedReceiveSession>(),
reason: "receiver should be in Closed state after cancel",
);
});
});

group("Test Async Persistence", () {
test("Test receiver async persistence", () async {
var persister = InMemoryReceiverPersisterAsync("1");
Expand Down
74 changes: 74 additions & 0 deletions payjoin-ffi/javascript/test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,80 @@ describe("Persistence tests", () => {
});
});

describe("Receiver cancel tests", () => {
test("receiver cancel from initialized", () => {
const persister = new InMemoryReceiverPersister(1);
const address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
const ohttpKeys = payjoin.OhttpKeys.decode(
new Uint8Array([
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
0x00, 0x01, 0x00, 0x03,
]).buffer,
);

const initialized = new payjoin.ReceiverBuilder(
address,
"https://example.com",
ohttpKeys,
)
.build()
.save(persister);
const cancelTransition = initialized.cancel();
const fallbackTx = cancelTransition.save(persister);
assert.strictEqual(fallbackTx, undefined);

const result = payjoin.replayReceiverEventLog(persister);
const state = result.state();
assert.strictEqual(
state.tag,
"Closed",
"State should be Closed after cancel",
);
});

test("receiver cancel async from initialized", async () => {
const persister = new InMemoryReceiverPersisterAsync(1);
const address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
const ohttpKeys = payjoin.OhttpKeys.decode(
new Uint8Array([
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
0x00, 0x01, 0x00, 0x03,
]).buffer,
);

const initialized = await new payjoin.ReceiverBuilder(
address,
"https://example.com",
ohttpKeys,
)
.build()
.saveAsync(persister);
const cancelTransition = initialized.cancel();
const fallbackTx = await cancelTransition.saveAsync(persister);
assert.strictEqual(fallbackTx, undefined);

const result = await payjoin.replayReceiverEventLogAsync(persister);
const state = result.state();
assert.strictEqual(
state.tag,
"Closed",
"State should be Closed after cancel",
);
});
});

describe("Async Persistence tests", () => {
test("receiver async persistence", async () => {
const persister = new InMemoryReceiverPersisterAsync(1);
Expand Down
51 changes: 51 additions & 0 deletions payjoin-ffi/python/test/test_payjoin_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,57 @@ async def run_test():
asyncio.run(run_test())


class TestReceiverCancel(unittest.TestCase):
def test_receiver_cancel(self):
persister = InMemoryReceiverPersister(1)
initialized = (
payjoin.ReceiverBuilder(
"tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
"https://example.com",
payjoin.OhttpKeys.decode(
bytes.fromhex(
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
)
),
)
.build()
.save(persister)
)
cancel_transition = initialized.cancel()
fallback_tx = cancel_transition.save(persister)
self.assertIsNone(fallback_tx)
result = payjoin.replay_receiver_event_log(persister)
self.assertTrue(result.state().is_CLOSED())


class TestReceiverCancelAsync(unittest.TestCase):
def test_receiver_cancel_async(self):
import asyncio

async def run_test():
persister = InMemoryReceiverPersisterAsync(1)
initialized = await (
payjoin.ReceiverBuilder(
"tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
"https://example.com",
payjoin.OhttpKeys.decode(
bytes.fromhex(
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
)
),
)
.build()
.save_async(persister)
)
cancel_transition = initialized.cancel()
fallback_tx = await cancel_transition.save_async(persister)
self.assertIsNone(fallback_tx)
result = await payjoin.replay_receiver_event_log_async(persister)
self.assertTrue(result.state().is_CLOSED())

asyncio.run(run_test())


class TestValidation(unittest.TestCase):
def test_receiver_builder_rejects_bad_address(self):
with self.assertRaises(payjoin.ReceiverBuilderError):
Expand Down
82 changes: 82 additions & 0 deletions payjoin-ffi/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,88 @@ macro_rules! impl_save_for_transition {
};
}

/// A terminal transition produced by cancelling a receiver session.
#[derive(uniffi::Object)]
pub struct CancelTransition {
transition: RwLock<
Option<
payjoin::persist::TerminalTransition<
payjoin::receive::v2::SessionEvent,
Option<payjoin::bitcoin::Transaction>,
>,
>,
>,
}

#[uniffi::export]
impl CancelTransition {
/// Persist the cancellation and return the fallback transaction if available.
///
/// The fallback transaction is the consensus-encoded raw transaction bytes,
/// or `None` if the session was cancelled before the sender's original
/// proposal was received.
pub fn save(
&self,
persister: Arc<dyn JsonReceiverSessionPersister>,
) -> Result<Option<Vec<u8>>, ReceiverPersistedError> {
let adapter = CallbackPersisterAdapter::new(persister);
let mut inner = self.transition.write().expect("Lock should not be poisoned");
let value = inner.take().expect("Already saved or moved");
let fallback = value
.save(&adapter)
.map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?;
Ok(fallback.map(|tx| payjoin::bitcoin::consensus::serialize(&tx)))
}

pub async fn save_async(
&self,
persister: Arc<dyn JsonReceiverSessionPersisterAsync>,
) -> Result<Option<Vec<u8>>, ReceiverPersistedError> {
let adapter = AsyncCallbackPersisterAdapter::new(persister);
let value = {
let mut inner = self.transition.write().expect("Lock should not be poisoned");
inner.take().expect("Already saved or moved")
};
let fallback = value
.save_async(&adapter)
.await
.map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?;
Ok(fallback.map(|tx| payjoin::bitcoin::consensus::serialize(&tx)))
}
}

macro_rules! impl_cancel_for_receiver {
($ty:ident) => {
#[uniffi::export]
impl $ty {
/// Cancel the Payjoin session immediately.
///
/// Returns a [`CancelTransition`] that, once persisted, yields the fallback
/// transaction when applicable. The fallback transaction is the sender's original
/// transaction that should be broadcast to complete the payment without Payjoin.
///
/// This is a terminal transition — the session cannot be used after cancellation.
pub fn cancel(&self) -> CancelTransition {
let transition = self.0.clone().cancel();
CancelTransition { transition: RwLock::new(Some(transition)) }
}
}
};
}

impl_cancel_for_receiver!(Initialized);
impl_cancel_for_receiver!(UncheckedOriginalPayload);
impl_cancel_for_receiver!(MaybeInputsOwned);
impl_cancel_for_receiver!(MaybeInputsSeen);
impl_cancel_for_receiver!(OutputsUnknown);
impl_cancel_for_receiver!(WantsOutputs);
impl_cancel_for_receiver!(WantsInputs);
impl_cancel_for_receiver!(WantsFeeRange);
impl_cancel_for_receiver!(ProvisionalProposal);
impl_cancel_for_receiver!(PayjoinProposal);
impl_cancel_for_receiver!(HasReplyableError);
impl_cancel_for_receiver!(Monitor);

#[derive(Debug, Clone, uniffi::Object)]
pub struct ReceiverSessionEvent(payjoin::receive::v2::SessionEvent);

Expand Down
34 changes: 34 additions & 0 deletions payjoin/src/core/persist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,40 @@ impl<Event, NextState> NextStateTransition<Event, NextState> {
}
}

/// A transition that unconditionally terminates the session.
///
/// Unlike other transition types, this always succeeds at the protocol level
/// (the only possible error is from the persister's storage layer).
/// After saving, the session is closed and no further events can be appended.
///
/// The `T` parameter carries a value that is returned after saving without
/// being persisted. This lets callers receive derived data (e.g. a fallback
/// transaction) through the same `.save()` call pattern used by every other
/// transition type.
pub struct TerminalTransition<Event, T>(Event, T);

impl<Event, T> TerminalTransition<Event, T> {
pub(crate) fn new(event: Event, value: T) -> Self { Self(event, value) }

pub fn save<P>(self, persister: &P) -> Result<T, P::InternalStorageError>
where
P: SessionPersister<SessionEvent = Event>,
{
PersistActions::SaveAndClose(self.0).execute(persister)?;
Ok(self.1)
}

pub async fn save_async<P>(self, persister: &P) -> Result<T, P::InternalStorageError>
where
P: AsyncSessionPersister<SessionEvent = Event>,
Event: Send,
T: Send,
{
PersistActions::SaveAndClose(self.0).execute_async(persister).await?;
Ok(self.1)
}
}

/// A transition that can result in a succession completion, fatal error, or transient error.
/// The transition can also result in no state change.
pub enum MaybeFatalOrSuccessTransition<Event, CurrentState, Err> {
Expand Down
2 changes: 1 addition & 1 deletion payjoin/src/core/receive/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::receive::{InternalPayloadError, OriginalPayload, PsbtContext};
/// Call [`Self::commit_outputs`] to proceed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WantsOutputs {
original_psbt: Psbt,
pub(super) original_psbt: Psbt,
pub(super) payjoin_psbt: Psbt,
pub(super) params: Params,
change_vout: usize,
Expand Down
Loading
Loading