diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 21e0189b2e899564daebf3a07e9e6c3d27e5bbe6..df38a6a8109fdccdb5658f3fca9f35ace576a8f6 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -73,6 +73,8 @@ from .model import ( Error as model_Error, ) +# The number of tokens to submit with a voucher redemption. +NUM_TOKENS = 100 class AlreadySpent(Exception): """ @@ -513,34 +515,47 @@ class PaymentController(object): _unpaid = attr.ib(default=attr.Factory(dict)) _active = attr.ib(default=attr.Factory(dict)) - def redeem(self, voucher, num_tokens=100): + def __attrs_post_init__(self): """ - :param unicode voucher: A voucher to redeem. + Check the voucher store for any vouchers in need of redemption. - :param int num_tokens: A number of tokens to redeem. + This is an initialization-time hook called by attrs. """ - # Pre-generate the random tokens to use when redeeming the voucher. - # These are persisted with the voucher so the redemption can be made - # idempotent. We don't want to lose the value if we fail after the - # server deems the voucher redeemed but before we persist the result. - # With a stable set of tokens, we can re-submit them and the server - # can re-sign them without fear of issuing excess passes. Whether the - # server signs a given set of random tokens once or many times, the - # number of passes that can be constructed is still only the size of - # the set of random tokens. - self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) - tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens) + self._check_pending_vouchers() - # Persist the voucher and tokens so they're available if we fail. - self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher) - self.store.add(voucher, tokens) + def _check_pending_vouchers(self): + """ + Find vouchers in the voucher store that need to be redeemed and try to + redeem them. + """ + vouchers = self.store.list() + for voucher in vouchers: + if voucher.state.should_start_redemption(): + self._log.info( + "Controller found voucher ({}) at startup that needs redemption.", + voucher=voucher.number, + ) + random_tokens = self._get_random_tokens_for_voucher(voucher.number, NUM_TOKENS) + self._perform_redeem(voucher.number, random_tokens) + else: + self._log.info( + "Controller found voucher ({}) at startup that does not need redemption.", + voucher=voucher.number, + ) + + def _perform_redeem(self, voucher, random_tokens): + """ + Use the redeemer to redeem the given voucher and random tokens. + This will not persist the voucher or random tokens but it will persist + the result. + """ # Ask the redeemer to do the real task of redemption. self._log.info("Redeeming random tokens for a voucher ({voucher}).", voucher=voucher) d = bracket( lambda: setitem(self._active, voucher, self.store.now()), lambda: delitem(self._active, voucher), - lambda: self.redeemer.redeem(Voucher(voucher), tokens), + lambda: self.redeemer.redeem(Voucher(voucher), random_tokens), ) d.addCallbacks( partial(self._redeemSuccess, voucher), @@ -549,6 +564,43 @@ class PaymentController(object): d.addErrback(partial(self._finalRedeemError, voucher)) return d + def _get_random_tokens_for_voucher(self, voucher, num_tokens): + """ + Generate or load random tokens for a redemption attempt of a voucher. + """ + self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) + tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens) + + self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher) + self.store.add(voucher, tokens) + + # XXX If the voucher is already in the store then the tokens passed to + # `add` are dropped and we need to get the tokens already persisted in + # the store. add should probably return them so we can use them. Or + # maybe we should pass the generator in so it can be called only if + # necessary. + return tokens + + def redeem(self, voucher, num_tokens=None): + """ + :param unicode voucher: A voucher to redeem. + + :param int num_tokens: A number of tokens to redeem. + """ + # Pre-generate the random tokens to use when redeeming the voucher. + # These are persisted with the voucher so the redemption can be made + # idempotent. We don't want to lose the value if we fail after the + # server deems the voucher redeemed but before we persist the result. + # With a stable set of tokens, we can re-submit them and the server + # can re-sign them without fear of issuing excess passes. Whether the + # server signs a given set of random tokens once or many times, the + # number of passes that can be constructed is still only the size of + # the set of random tokens. + if num_tokens is None: + num_tokens = NUM_TOKENS + tokens = self._get_random_tokens_for_voucher(voucher, num_tokens) + return self._perform_redeem(voucher, tokens) + def _redeemSuccess(self, voucher, unblinded_tokens): """ Update the database state to reflect that a voucher was redeemed and to @@ -587,6 +639,11 @@ class PaymentController(object): self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher) return None + def get_voucher(self, number): + return self.incorporate_transient_state( + self.store.get(number), + ) + def incorporate_transient_state(self, voucher): """ Create a new ``Voucher`` which represents the given voucher but which also diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 8af6d83234719a7525b802a3c2d00173a6f55c48..65d653ddf94ceaa3435fbaf9a2bb690029f4de4b 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -466,6 +466,9 @@ class RandomToken(object): @attr.s(frozen=True) class Pending(object): + def should_start_redemption(self): + return True + def to_json_v1(self): return { u"name": u"pending", @@ -481,6 +484,9 @@ class Redeeming(object): """ started = attr.ib(validator=attr.validators.instance_of(datetime)) + def should_start_redemption(self): + return False + def to_json_v1(self): return { u"name": u"redeeming", @@ -493,6 +499,9 @@ class Redeemed(object): finished = attr.ib(validator=attr.validators.instance_of(datetime)) token_count = attr.ib(validator=attr.validators.instance_of((int, long))) + def should_start_redemption(self): + return False + def to_json_v1(self): return { u"name": u"redeemed", @@ -505,6 +514,9 @@ class Redeemed(object): class DoubleSpend(object): finished = attr.ib(validator=attr.validators.instance_of(datetime)) + def should_start_redemption(self): + return False + def to_json_v1(self): return { u"name": u"double-spend", @@ -521,6 +533,9 @@ class Unpaid(object): """ finished = attr.ib(validator=attr.validators.instance_of(datetime)) + def should_start_redemption(self): + return True + def to_json_v1(self): return { u"name": u"unpaid", @@ -538,6 +553,9 @@ class Error(object): finished = attr.ib(validator=attr.validators.instance_of(datetime)) details = attr.ib(validator=attr.validators.instance_of(unicode)) + def should_start_redemption(self): + return True + def to_json_v1(self): return { u"name": u"error", diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 93cab715a80861c96c78d5aa42bdbb43651a1029..1684e626808cb24acb5d0b2f5ca9629cb9071e0e 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -97,6 +97,7 @@ from ..controller import ( NonRedeemer, DummyRedeemer, DoubleSpendRedeemer, + UnpaidRedeemer, RistrettoRedeemer, PaymentController, AlreadySpent, @@ -110,6 +111,7 @@ from ..model import ( Pending as model_Pending, DoubleSpend as model_DoubleSpend, Redeemed as model_Redeemed, + Unpaid as model_Unpaid, ) from .strategies import ( @@ -212,6 +214,48 @@ class PaymentControllerTests(TestCase): ), ) + @given(tahoe_configs(), datetimes(), vouchers()) + def test_redeem_pending_on_startup(self, get_config, now, voucher): + """ + When ``PaymentController`` is created, any vouchers in the store in the + pending state are redeemed. + """ + tempdir = self.useFixture(TempDir()) + store = VoucherStore.from_node_config( + get_config( + tempdir.join(b"node"), + b"tub.port", + ), + now=lambda: now, + connect=memory_connect, + ) + # Create the voucher state in the store with a redemption that will + # certainly fail. + unpaid_controller = PaymentController( + store, + UnpaidRedeemer(), + ) + unpaid_controller.redeem(voucher) + + # Make sure we got where we wanted. + self.assertThat( + unpaid_controller.get_voucher(voucher).state, + IsInstance(model_Unpaid), + ) + + # Create another controller with the same store. It will see the + # voucher state and attempt a redemption on its own. It has I/O as an + # `__init__` side-effect. :/ + success_controller = PaymentController( + store, + DummyRedeemer(), + ) + + self.assertThat( + success_controller.get_voucher(voucher).state, + IsInstance(model_Redeemed), + ) + NOWHERE = URL.from_text(u"https://127.0.0.1/")