From 3a81cab627289bbdfd7c386ce2b24e217cdc4efa Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone <exarkun@twistedmatrix.com> Date: Fri, 15 Nov 2019 10:48:40 -0500 Subject: [PATCH] Teach PaymentController what to do on AlreadySpent errors --- src/_zkapauthorizer/controller.py | 55 +++++++++++++++++--- src/_zkapauthorizer/tests/test_controller.py | 34 ++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 56dae0b..b1c4d4b 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -39,6 +39,7 @@ from twisted.python.url import ( from twisted.internet.defer import ( Deferred, succeed, + fail, inlineCallbacks, returnValue, ) @@ -62,6 +63,13 @@ from .model import ( ) +class AlreadySpent(Exception): + """ + An attempt was made to redeem a voucher which has already been redeemed. + The redemption cannot succeed and should not be retried automatically. + """ + + class IRedeemer(Interface): """ An ``IRedeemer`` can exchange a voucher for one or more passes. @@ -100,7 +108,10 @@ class IRedeemer(Interface): :return: A ``Deferred`` which fires with a list of ``UnblindedToken`` instances on successful redemption or which fails with any error - to allow a retry to be made at some future point. + to allow a retry to be made at some future point. It may also + fail with an ``AlreadySpent`` error to indicate the redemption + server considers the voucher to have been redeemed already and + will not allow it to be redeemed. """ def tokens_to_passes(message, unblinded_tokens): @@ -150,6 +161,28 @@ class NonRedeemer(object): ) +@implementer(IRedeemer) +@attr.s +class DoubleSpentRedeemer(object): + """ + A ``DoubleSpentRedeemer`` pretends to try to redeem vouchers for ZKAPs but + always fails with an error indicating the voucher has already been spent. + """ + def random_tokens_for_voucher(self, voucher, count): + return dummy_random_tokens(voucher, count) + + def redeem(self, voucher, random_tokens): + return fail(AlreadySpent(voucher)) + + +def dummy_random_tokens(voucher, count): + return list( + RandomToken(u"{}-{}".format(voucher.number, n)) + for n + in range(count) + ) + + @implementer(IRedeemer) @attr.s class DummyRedeemer(object): @@ -167,12 +200,7 @@ class DummyRedeemer(object): Generate some number of random tokens to submit along with a voucher for redemption. """ - # Dummy token generation. - return list( - RandomToken(u"{}-{}".format(voucher.number, n)) - for n - in range(count) - ) + return dummy_random_tokens(voucher, count) def redeem(self, voucher, random_tokens): """ @@ -431,7 +459,18 @@ class PaymentController(object): self.store.insert_unblinded_tokens_for_voucher(voucher, unblinded_tokens) def _redeemFailure(self, voucher, reason): - self._log.failure("Redeeming random tokens for a voucher ({voucher}) failed.", reason, voucher=voucher) + if reason.check(AlreadySpent): + self._log.error( + "Voucher {voucher} reported as already spent during redemption.", + voucher=voucher, + ) + self.store.mark_voucher_double_spent(voucher) + else: + self._log.failure( + "Redeeming random tokens for a voucher ({voucher}) failed.", + reason, + voucher=voucher, + ) return None def _finalRedeemError(self, voucher, reason): diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index bb72c66..17876ca 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -43,6 +43,7 @@ from testtools.matchers import ( IsInstance, HasLength, AfterPreprocessing, + MatchesStructure, ) from testtools.twistedsupport import ( succeeded, @@ -94,6 +95,7 @@ from ..controller import ( IRedeemer, NonRedeemer, DummyRedeemer, + DoubleSpentRedeemer, RistrettoRedeemer, PaymentController, ) @@ -146,6 +148,9 @@ class PaymentControllerTests(TestCase): @given(tahoe_configs(), datetimes(), vouchers()) def test_redeemed_after_redeeming(self, get_config, now, voucher): + """ + A ``Voucher`` is marked as redeemed after ``IRedeemer.redeem`` succeeds. + """ tempdir = self.useFixture(TempDir()) store = VoucherStore.from_node_config( get_config( @@ -167,6 +172,35 @@ class PaymentControllerTests(TestCase): Equals(u"redeemed"), ) + @given(tahoe_configs(), datetimes(), vouchers()) + def test_double_spent_after_double_spend(self, get_config, now, voucher): + """ + A ``Voucher`` is marked as double-spent after ``IRedeemer.redeem`` fails + with ``AlreadySpent``. + """ + tempdir = self.useFixture(TempDir()) + store = VoucherStore.from_node_config( + get_config( + tempdir.join(b"node"), + b"tub.port", + ), + now=lambda: now, + connect=memory_connect, + ) + controller = PaymentController( + store, + DoubleSpentRedeemer(), + ) + controller.redeem(voucher) + + persisted_voucher = store.get(voucher) + self.assertThat( + persisted_voucher, + MatchesStructure( + state=Equals(u"double-spend"), + ), + ) + NOWHERE = URL.from_text(u"https://127.0.0.1/") -- GitLab