Skip to content

Commit 3f2e13e

Browse files
committed
Add receiver cancel() API to FFI bindings
1 parent 0f711e8 commit 3f2e13e

File tree

5 files changed

+312
-0
lines changed

5 files changed

+312
-0
lines changed

payjoin-ffi/csharp/UnitTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,59 @@ public async Task SenderPersistenceAsync()
223223
}
224224
}
225225

226+
public class CancelTests
227+
{
228+
private static readonly byte[] OhttpKeysData = new byte[]
229+
{
230+
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
231+
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
232+
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
233+
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
234+
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
235+
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
236+
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
237+
0x00, 0x01, 0x00, 0x03,
238+
};
239+
240+
[Fact]
241+
public void ReceiverCancelFromInitialized()
242+
{
243+
var persister = new InMemoryReceiverPersister();
244+
var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
245+
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);
246+
247+
var initialized = new ReceiverBuilder(address, "https://example.com", ohttpKeys)
248+
.Build()
249+
.Save(persister);
250+
var cancelTransition = initialized.Cancel();
251+
var fallbackTx = cancelTransition.Save(persister);
252+
Assert.Null(fallbackTx);
253+
254+
var result = PayjoinMethods.ReplayReceiverEventLog(persister);
255+
var state = result.State();
256+
Assert.IsType<ReceiveSession.Closed>(state);
257+
}
258+
259+
[Fact]
260+
public async Task ReceiverCancelFromInitializedAsync()
261+
{
262+
var persister = new InMemoryReceiverPersisterAsync();
263+
var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
264+
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);
265+
266+
var initialized = await new ReceiverBuilder(address, "https://example.com", ohttpKeys)
267+
.Build()
268+
.SaveAsync(persister);
269+
var cancelTransition = initialized.Cancel();
270+
var fallbackTx = await cancelTransition.SaveAsync(persister);
271+
Assert.Null(fallbackTx);
272+
273+
var result = await PayjoinMethods.ReplayReceiverEventLogAsync(persister);
274+
var state = result.State();
275+
Assert.IsType<ReceiveSession.Closed>(state);
276+
}
277+
}
278+
226279
public class ValidationTests
227280
{
228281
private static readonly byte[] OhttpKeysData = new byte[]

payjoin-ffi/dart/test/test_payjoin_unit_test.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,58 @@ void main() {
204204
});
205205
});
206206

207+
group("Test Receiver Cancel", () {
208+
test("Test receiver cancel from initialized", () {
209+
var persister = InMemoryReceiverPersister("1");
210+
var initialized = payjoin.ReceiverBuilder(
211+
address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
212+
directory: "https://example.com",
213+
ohttpKeys: payjoin.OhttpKeys.decode(
214+
bytes: Uint8List.fromList(
215+
hex.decode(
216+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003",
217+
),
218+
),
219+
),
220+
).build().save(persister: persister);
221+
var cancelTransition = initialized.cancel();
222+
var fallbackTx = cancelTransition.save(persister: persister);
223+
expect(fallbackTx, isNull);
224+
final result = payjoin.replayReceiverEventLog(persister: persister);
225+
expect(
226+
result.state(),
227+
isA<payjoin.ClosedReceiveSession>(),
228+
reason: "receiver should be in Closed state after cancel",
229+
);
230+
});
231+
232+
test("Test receiver cancel async from initialized", () async {
233+
var persister = InMemoryReceiverPersisterAsync("1");
234+
var initialized = await payjoin.ReceiverBuilder(
235+
address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
236+
directory: "https://example.com",
237+
ohttpKeys: payjoin.OhttpKeys.decode(
238+
bytes: Uint8List.fromList(
239+
hex.decode(
240+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003",
241+
),
242+
),
243+
),
244+
).build().saveAsync(persister: persister);
245+
var cancelTransition = initialized.cancel();
246+
var fallbackTx = await cancelTransition.saveAsync(persister: persister);
247+
expect(fallbackTx, isNull);
248+
final result = await payjoin.replayReceiverEventLogAsync(
249+
persister: persister,
250+
);
251+
expect(
252+
result.state(),
253+
isA<payjoin.ClosedReceiveSession>(),
254+
reason: "receiver should be in Closed state after cancel",
255+
);
256+
});
257+
});
258+
207259
group("Test Async Persistence", () {
208260
test("Test receiver async persistence", () async {
209261
var persister = InMemoryReceiverPersisterAsync("1");

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,80 @@ describe("Persistence tests", () => {
220220
});
221221
});
222222

223+
describe("Receiver cancel tests", () => {
224+
test("receiver cancel from initialized", () => {
225+
const persister = new InMemoryReceiverPersister(1);
226+
const address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
227+
const ohttpKeys = payjoin.OhttpKeys.decode(
228+
new Uint8Array([
229+
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
230+
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
231+
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
232+
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
233+
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
234+
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
235+
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
236+
0x00, 0x01, 0x00, 0x03,
237+
]).buffer,
238+
);
239+
240+
const initialized = new payjoin.ReceiverBuilder(
241+
address,
242+
"https://example.com",
243+
ohttpKeys,
244+
)
245+
.build()
246+
.save(persister);
247+
const cancelTransition = initialized.cancel();
248+
const fallbackTx = cancelTransition.save(persister);
249+
assert.strictEqual(fallbackTx, undefined);
250+
251+
const result = payjoin.replayReceiverEventLog(persister);
252+
const state = result.state();
253+
assert.strictEqual(
254+
state.tag,
255+
"Closed",
256+
"State should be Closed after cancel",
257+
);
258+
});
259+
260+
test("receiver cancel async from initialized", async () => {
261+
const persister = new InMemoryReceiverPersisterAsync(1);
262+
const address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
263+
const ohttpKeys = payjoin.OhttpKeys.decode(
264+
new Uint8Array([
265+
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
266+
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
267+
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
268+
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
269+
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
270+
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
271+
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
272+
0x00, 0x01, 0x00, 0x03,
273+
]).buffer,
274+
);
275+
276+
const initialized = await new payjoin.ReceiverBuilder(
277+
address,
278+
"https://example.com",
279+
ohttpKeys,
280+
)
281+
.build()
282+
.saveAsync(persister);
283+
const cancelTransition = initialized.cancel();
284+
const fallbackTx = await cancelTransition.saveAsync(persister);
285+
assert.strictEqual(fallbackTx, undefined);
286+
287+
const result = await payjoin.replayReceiverEventLogAsync(persister);
288+
const state = result.state();
289+
assert.strictEqual(
290+
state.tag,
291+
"Closed",
292+
"State should be Closed after cancel",
293+
);
294+
});
295+
});
296+
223297
describe("Async Persistence tests", () => {
224298
test("receiver async persistence", async () => {
225299
const persister = new InMemoryReceiverPersisterAsync(1);

payjoin-ffi/python/test/test_payjoin_unit_test.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,57 @@ async def run_test():
196196
asyncio.run(run_test())
197197

198198

199+
class TestReceiverCancel(unittest.TestCase):
200+
def test_receiver_cancel(self):
201+
persister = InMemoryReceiverPersister(1)
202+
initialized = (
203+
payjoin.ReceiverBuilder(
204+
"tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
205+
"https://example.com",
206+
payjoin.OhttpKeys.decode(
207+
bytes.fromhex(
208+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
209+
)
210+
),
211+
)
212+
.build()
213+
.save(persister)
214+
)
215+
cancel_transition = initialized.cancel()
216+
fallback_tx = cancel_transition.save(persister)
217+
self.assertIsNone(fallback_tx)
218+
result = payjoin.replay_receiver_event_log(persister)
219+
self.assertTrue(result.state().is_CLOSED())
220+
221+
222+
class TestReceiverCancelAsync(unittest.TestCase):
223+
def test_receiver_cancel_async(self):
224+
import asyncio
225+
226+
async def run_test():
227+
persister = InMemoryReceiverPersisterAsync(1)
228+
initialized = await (
229+
payjoin.ReceiverBuilder(
230+
"tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4",
231+
"https://example.com",
232+
payjoin.OhttpKeys.decode(
233+
bytes.fromhex(
234+
"01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"
235+
)
236+
),
237+
)
238+
.build()
239+
.save_async(persister)
240+
)
241+
cancel_transition = initialized.cancel()
242+
fallback_tx = await cancel_transition.save_async(persister)
243+
self.assertIsNone(fallback_tx)
244+
result = await payjoin.replay_receiver_event_log_async(persister)
245+
self.assertTrue(result.state().is_CLOSED())
246+
247+
asyncio.run(run_test())
248+
249+
199250
class TestValidation(unittest.TestCase):
200251
def test_receiver_builder_rejects_bad_address(self):
201252
with self.assertRaises(payjoin.ReceiverBuilderError):

payjoin-ffi/src/receive/mod.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,88 @@ macro_rules! impl_save_for_transition {
6565
};
6666
}
6767

68+
/// A terminal transition produced by cancelling a receiver session.
69+
#[derive(uniffi::Object)]
70+
pub struct CancelTransition {
71+
transition: RwLock<
72+
Option<
73+
payjoin::persist::TerminalTransition<
74+
payjoin::receive::v2::SessionEvent,
75+
Option<payjoin::bitcoin::Transaction>,
76+
>,
77+
>,
78+
>,
79+
}
80+
81+
#[uniffi::export]
82+
impl CancelTransition {
83+
/// Persist the cancellation and return the fallback transaction if available.
84+
///
85+
/// The fallback transaction is the consensus-encoded raw transaction bytes,
86+
/// or `None` if the session was cancelled before the sender's original
87+
/// proposal was received.
88+
pub fn save(
89+
&self,
90+
persister: Arc<dyn JsonReceiverSessionPersister>,
91+
) -> Result<Option<Vec<u8>>, ReceiverPersistedError> {
92+
let adapter = CallbackPersisterAdapter::new(persister);
93+
let mut inner = self.transition.write().expect("Lock should not be poisoned");
94+
let value = inner.take().expect("Already saved or moved");
95+
let fallback = value
96+
.save(&adapter)
97+
.map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?;
98+
Ok(fallback.map(|tx| payjoin::bitcoin::consensus::serialize(&tx)))
99+
}
100+
101+
pub async fn save_async(
102+
&self,
103+
persister: Arc<dyn JsonReceiverSessionPersisterAsync>,
104+
) -> Result<Option<Vec<u8>>, ReceiverPersistedError> {
105+
let adapter = AsyncCallbackPersisterAdapter::new(persister);
106+
let value = {
107+
let mut inner = self.transition.write().expect("Lock should not be poisoned");
108+
inner.take().expect("Already saved or moved")
109+
};
110+
let fallback = value
111+
.save_async(&adapter)
112+
.await
113+
.map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?;
114+
Ok(fallback.map(|tx| payjoin::bitcoin::consensus::serialize(&tx)))
115+
}
116+
}
117+
118+
macro_rules! impl_cancel_for_receiver {
119+
($ty:ident) => {
120+
#[uniffi::export]
121+
impl $ty {
122+
/// Cancel the Payjoin session immediately.
123+
///
124+
/// Returns a [`CancelTransition`] that, once persisted, yields the fallback
125+
/// transaction when applicable. The fallback transaction is the sender's original
126+
/// transaction that should be broadcast to complete the payment without Payjoin.
127+
///
128+
/// This is a terminal transition — the session cannot be used after cancellation.
129+
pub fn cancel(&self) -> CancelTransition {
130+
let transition = self.0.clone().cancel();
131+
CancelTransition { transition: RwLock::new(Some(transition)) }
132+
}
133+
}
134+
};
135+
}
136+
137+
impl_cancel_for_receiver!(Initialized);
138+
impl_cancel_for_receiver!(UncheckedOriginalPayload);
139+
impl_cancel_for_receiver!(MaybeInputsOwned);
140+
impl_cancel_for_receiver!(MaybeInputsSeen);
141+
impl_cancel_for_receiver!(OutputsUnknown);
142+
impl_cancel_for_receiver!(WantsOutputs);
143+
impl_cancel_for_receiver!(WantsInputs);
144+
impl_cancel_for_receiver!(WantsFeeRange);
145+
impl_cancel_for_receiver!(ProvisionalProposal);
146+
impl_cancel_for_receiver!(PayjoinProposal);
147+
impl_cancel_for_receiver!(HasReplyableError);
148+
impl_cancel_for_receiver!(Monitor);
149+
68150
#[derive(Debug, Clone, uniffi::Object)]
69151
pub struct ReceiverSessionEvent(payjoin::receive::v2::SessionEvent);
70152

0 commit comments

Comments
 (0)