diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index dc9db3bd8983c5bb6d2d9cdc0edad1506846f12a..77436a8bdebbbcd1c2b79631f8f614bdcf023009 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -41,6 +41,10 @@ from base64 import (
     b64encode,
     b64decode,
 )
+from hashlib import (
+    sha256,
+)
+
 import attr
 
 from zope.interface import (
@@ -133,7 +137,7 @@ class IRedeemer(Interface):
     """
     An ``IRedeemer`` can exchange a voucher for one or more passes.
     """
-    def random_tokens_for_voucher(voucher, count):
+    def random_tokens_for_voucher(voucher, counter, count):
         """
         Generate a number of random tokens to use in the redemption process for
         the given voucher.
@@ -141,6 +145,8 @@ class IRedeemer(Interface):
         :param Voucher voucher: The voucher the tokens will be associated
             with.
 
+        :param int counter: See ``redeemWithCounter``.
+
         :param int count: The number of random tokens to generate.
 
         :return list[RandomToken]: The generated tokens.  Random tokens must
@@ -199,6 +205,34 @@ class IRedeemer(Interface):
         """
 
 
+@attr.s
+@implementer(IRedeemer)
+class IndexedRedeemer(object):
+    """
+    A ``IndexedRedeemer`` delegates redemption to a redeemer chosen to
+    correspond to the redemption counter given.
+    """
+    _log = Logger()
+
+    redeemers = attr.ib()
+
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
+
+    def redeemWithCounter(self, voucher, counter, random_tokens):
+        self._log.info(
+            "IndexedRedeemer redeeming {voucher}[{counter}] using {delegate}.",
+            voucher=voucher,
+            counter=counter,
+            delegate=self.redeemers[counter],
+        )
+        return self.redeemers[counter].redeemWithCounter(
+            voucher,
+            counter,
+            random_tokens,
+        )
+
+
 @implementer(IRedeemer)
 class NonRedeemer(object):
     """
@@ -208,8 +242,8 @@ class NonRedeemer(object):
     def make(cls, section_name, node_config, announcement, reactor):
         return cls()
 
-    def random_tokens_for_voucher(self, voucher, count):
-        return dummy_random_tokens(voucher, count)
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
 
     def redeemWithCounter(self, voucher, counter, random_tokens):
         # Don't try to redeem them.
@@ -238,8 +272,8 @@ class ErrorRedeemer(object):
         ).decode("ascii")
         return cls(details)
 
-    def random_tokens_for_voucher(self, voucher, count):
-        return dummy_random_tokens(voucher, count)
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
 
     def redeemWithCounter(self, voucher, counter, random_tokens):
         return fail(Exception(self.details))
@@ -261,8 +295,8 @@ class DoubleSpendRedeemer(object):
     def make(cls, section_name, node_config, announcement, reactor):
         return cls()
 
-    def random_tokens_for_voucher(self, voucher, count):
-        return dummy_random_tokens(voucher, count)
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
 
     def redeemWithCounter(self, voucher, counter, random_tokens):
         return fail(AlreadySpent(voucher))
@@ -279,21 +313,39 @@ class UnpaidRedeemer(object):
     def make(cls, section_name, node_config, announcement, reactor):
         return cls()
 
-    def random_tokens_for_voucher(self, voucher, count):
-        return dummy_random_tokens(voucher, count)
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
 
     def redeemWithCounter(self, voucher, counter, random_tokens):
         return fail(Unpaid(voucher))
 
 
-def dummy_random_tokens(voucher, count):
+@implementer(IRedeemer)
+@attr.s
+class RecordingRedeemer(object):
+    """
+    A ``CountingRedeemer`` delegates redemption logic to another object but
+    records all redemption attempts.
+    """
+    original = attr.ib()
+    redemptions = attr.ib(default=attr.Factory(list))
+
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
+
+    def redeemWithCounter(self, voucher, counter, random_tokens):
+        self.redemptions.append((voucher, counter, random_tokens))
+        return self.original.redeemWithCounter(voucher, counter, random_tokens)
+
+
+def dummy_random_tokens(voucher, counter, count):
     v = urlsafe_b64decode(voucher.number.encode("ascii"))
     def dummy_random_token(n):
         return RandomToken(
             # Padding is 96 (random token length) - 32 (decoded voucher
-            # length)
+            # length) - 4 (fixed-width counter)
             b64encode(
-                v + u"{:0>64}".format(n).encode("ascii"),
+                v + u"{:0>4}{:0>60}".format(counter, n).encode("ascii"),
             ).decode("ascii"),
         )
     return list(
@@ -317,12 +369,12 @@ class DummyRedeemer(object):
     def make(cls, section_name, node_config, announcement, reactor):
         return cls()
 
-    def random_tokens_for_voucher(self, voucher, count):
+    def random_tokens_for_voucher(self, voucher, counter, count):
         """
         Generate some number of random tokens to submit along with a voucher for
         redemption.
         """
-        return dummy_random_tokens(voucher, count)
+        return dummy_random_tokens(voucher, counter, count)
 
     def redeemWithCounter(self, voucher, counter, random_tokens):
         """
@@ -352,11 +404,16 @@ class DummyRedeemer(object):
 
     def tokens_to_passes(self, message, unblinded_tokens):
         def token_to_pass(token):
-            # Smear the unblinded token value across the two new values we
-            # need.
-            bs = b64decode(token.unblinded_token.encode("ascii"))
-            preimage = bs[:48] + b"x" * 16
-            signature = bs[48:] + b"y" * 16
+            # Generate distinct strings based on the unblinded token which we
+            # can include in the resulting Pass.  This ensures the pass values
+            # will be unique if and only if the unblinded tokens were unique
+            # (barring improbable hash collisions).
+            token_digest = sha256(
+                token.unblinded_token.encode("ascii")
+            ).hexdigest().encode("ascii")
+
+            preimage = b"preimage-" + token_digest[len(b"preimage-"):]
+            signature = b"signature-" + token_digest[len(b"signature-"):]
             return Pass(
                 b64encode(preimage).decode("ascii"),
                 b64encode(signature).decode("ascii"),
@@ -437,7 +494,7 @@ class RistrettoRedeemer(object):
             URL.from_text(configured_issuer),
         )
 
-    def random_tokens_for_voucher(self, voucher, count):
+    def random_tokens_for_voucher(self, voucher, counter, count):
         return list(
             RandomToken(
                 challenge_bypass_ristretto.RandomToken.create().encode_base64().decode("ascii"),
@@ -561,6 +618,41 @@ class RistrettoRedeemer(object):
         return passes
 
 
+def token_count_for_group(num_groups, total_tokens, group_number):
+    """
+    Determine a number of tokens to retrieve for a particular group out of an
+    overall redemption attempt.
+
+    :param int num_groups: The total number of groups the tokens will be
+        divided into.
+
+    :param int total_tokens: The total number of tokens to divide up.
+
+    :param int group_number: The particular group for which to determine a
+        token count.
+
+    :return int: A number of tokens to redeem in this group.
+    """
+    if total_tokens < num_groups:
+        raise ValueError(
+            "Cannot distribute {} tokens among {} groups coherently.".format(
+                total_tokens,
+                num_groups,
+            ),
+        )
+    if group_number >= num_groups or group_number < 0:
+        raise ValueError(
+            "Group number {} is out of valid range [0..{})".format(
+                group_number,
+                num_groups,
+            ),
+        )
+    group_size, remainder = divmod(total_tokens, num_groups)
+    if group_number < remainder:
+        return group_size + 1
+    return group_size
+
+
 @attr.s
 class PaymentController(object):
     """
@@ -598,6 +690,15 @@ class PaymentController(object):
         which have recently failed a redemption attempt due to an unpaid
         response from the redemption server to timestamps when the failure was
         observed.
+
+    :ivar int num_redemption_groups: The number of groups into which to divide
+        tokens during the redemption process, with each group being redeemed
+        separately from the rest.  This value needs to agree with the value
+        the PaymentServer is configured with.
+
+        TODO: Retrieve this value from the PaymentServer or from the
+        ZKAPAuthorizer configuration instead of just hard-coding a duplicate
+        value in this implementation.
     """
     _log = Logger()
 
@@ -605,6 +706,8 @@ class PaymentController(object):
     redeemer = attr.ib()
     default_token_count = attr.ib()
 
+    num_redemption_groups = attr.ib(default=16)
+
     _clock = attr.ib(
         default=attr.Factory(partial(namedAny, "twisted.internet.reactor")),
     )
@@ -663,12 +766,89 @@ class PaymentController(object):
                     voucher=voucher.number,
                 )
 
+    def _get_random_tokens_for_voucher(self, voucher, counter, num_tokens):
+        """
+        Generate or load random tokens for a redemption attempt of a voucher.
+        """
+        def get_tokens():
+            self._log.info(
+                "Generating random tokens for a voucher ({voucher}).",
+                voucher=voucher,
+            )
+            return self.redeemer.random_tokens_for_voucher(
+                Voucher(voucher),
+                counter,
+                num_tokens,
+            )
+
+        return self.store.add(voucher, counter, get_tokens)
+
+    @inlineCallbacks
+    def redeem(self, voucher, num_tokens=None):
+        """
+        :param unicode voucher: A voucher to redeem.
+
+        :param int num_tokens: A number of tokens to redeem.
+        """
+        if num_tokens is None:
+            num_tokens = self.default_token_count
+
+        # Try to get an existing voucher object for the given number.
+        try:
+            voucher_obj = self.store.get(voucher)
+        except KeyError:
+            # This is our first time dealing with this number.
+            counter_start = 0
+        else:
+            # Determine the starting point from the state.
+            if voucher_obj.state.should_start_redemption():
+                counter_start = voucher_obj.state.counter
+            else:
+                raise ValueError(
+                    "Cannot redeem voucher in state {}.".format(
+                        voucher_obj.state,
+                    ),
+                )
+
+        self._log.info(
+            "Starting redemption of {voucher}[{start}..{end}]",
+            voucher=voucher,
+            start=counter_start,
+            end=self.num_redemption_groups,
+        )
+        for counter in range(counter_start, self.num_redemption_groups):
+            # 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.
+            token_count = token_count_for_group(self.num_redemption_groups, num_tokens, counter)
+            tokens = self._get_random_tokens_for_voucher(voucher, counter, token_count)
+
+            # Reload state before each iteration.  We expect it to change each time.
+            voucher_obj = self.store.get(voucher)
+
+            succeeded = yield self._perform_redeem(voucher_obj, counter, tokens)
+            if not succeeded:
+                self._log.info(
+                    "Temporarily suspending redemption of {voucher} after non-success result.",
+                    voucher=voucher,
+                )
+                break
+
     def _perform_redeem(self, voucher, counter, 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.
+
+        :return Deferred[bool]: A ``Deferred`` firing with ``True`` if and
+            only if redemption succeeds.
         """
         if not isinstance(voucher.state, model_Pending):
             raise ValueError(
@@ -692,50 +872,13 @@ class PaymentController(object):
             lambda: self.redeemer.redeemWithCounter(voucher, counter, random_tokens),
         )
         d.addCallbacks(
-            partial(self._redeemSuccess, voucher.number),
-            partial(self._redeemFailure, voucher.number),
+            partial(self._redeem_success, voucher.number, counter),
+            partial(self._redeem_failure, voucher.number),
         )
-        d.addErrback(partial(self._finalRedeemError, voucher.number))
+        d.addErrback(partial(self._final_redeem_error, voucher.number))
         return d
 
-    def _get_random_tokens_for_voucher(self, voucher, num_tokens):
-        """
-        Generate or load random tokens for a redemption attempt of a voucher.
-        """
-        def get_tokens():
-            self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher)
-            return self.redeemer.random_tokens_for_voucher(Voucher(voucher), num_tokens)
-
-        return self.store.add(voucher, get_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 = self.default_token_count
-        tokens = self._get_random_tokens_for_voucher(voucher, num_tokens)
-        # TODO: Actually count up from the voucher's current counter value to
-        # maxCounter instead of only passing 0 here.  Starting at 0 is fine
-        # for a new voucher but if we partially redeemed a voucher on a
-        # previous run and this call comes from `_check_pending_vouchers` then
-        # we should skip any already-redeemed counter values.
-        #
-        # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/124
-        return self._perform_redeem(self.store.get(voucher), 0, tokens)
-
-    def _redeemSuccess(self, voucher, result):
+    def _redeem_success(self, voucher, counter, result):
         """
         Update the database state to reflect that a voucher was redeemed and to
         store the resulting unblinded tokens (which can be used to construct
@@ -749,9 +892,11 @@ class PaymentController(object):
             voucher,
             result.public_key,
             result.unblinded_tokens,
+            completed=(counter + 1 == self.num_redemption_groups),
         )
+        return True
 
-    def _redeemFailure(self, voucher, reason):
+    def _redeem_failure(self, voucher, reason):
         if reason.check(AlreadySpent):
             self._log.error(
                 "Voucher {voucher} reported as already spent during redemption.",
@@ -774,11 +919,15 @@ class PaymentController(object):
                 finished=self.store.now(),
                 details=reason.getErrorMessage().decode("utf-8", "replace"),
             )
-        return None
+        return False
 
-    def _finalRedeemError(self, voucher, reason):
-        self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher)
-        return None
+    def _final_redeem_error(self, voucher, reason):
+        self._log.failure(
+            "Redeeming random tokens for a voucher ({voucher}) encountered error.",
+            reason,
+            voucher=voucher,
+        )
+        return False
 
     def get_voucher(self, number):
         return self.incorporate_transient_state(
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 5e391c52c8575f4ecac9a7af0828cba811a22eef..baff206c28c14931f9d403d47b4938cccb756b10 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -232,12 +232,17 @@ class VoucherStore(object):
         return Voucher.from_row(refs[0])
 
     @with_cursor
-    def add(self, cursor, voucher, get_tokens):
+    def add(self, cursor, voucher, counter, get_tokens):
         """
-        Add a new voucher and associated random tokens to the database.  If a
-        voucher with the given text value is already present, do nothing.
+        Add random tokens associated with a voucher (possibly new, possibly
+        existing) to the database.  If the (voucher, counter) pair is already
+        present, do nothing.
 
-        :param unicode voucher: The text value of a voucher to add.
+        :param unicode voucher: The text value of a voucher with which to
+            associate the tokens.
+
+        :param int counter: The redemption counter for the given voucher with
+            which to associate the tokens.
 
         :param list[RandomToken]: The tokens to add alongside the voucher.
         """
@@ -250,16 +255,17 @@ class VoucherStore(object):
             """
             SELECT ([text])
             FROM [tokens]
-            WHERE [voucher] = ?
+            WHERE [voucher] = ? AND [counter] = ?
             """,
-            (voucher,),
+            (voucher, counter),
         )
         rows = cursor.fetchall()
         if len(rows) > 0:
             self._log.info(
-                "Loaded {count} random tokens for a voucher ({voucher}).",
+                "Loaded {count} random tokens for a voucher ({voucher}[{counter}]).",
                 count=len(rows),
                 voucher=voucher,
+                counter=counter,
             )
             tokens = list(
                 RandomToken(token_value)
@@ -269,9 +275,10 @@ class VoucherStore(object):
         else:
             tokens = get_tokens()
             self._log.info(
-                "Persisting {count} random tokens for a voucher ({voucher}).",
+                "Persisting {count} random tokens for a voucher ({voucher}[{counter}]).",
                 count=len(tokens),
                 voucher=voucher,
+                counter=counter,
             )
             cursor.execute(
                 """
@@ -281,10 +288,10 @@ class VoucherStore(object):
             )
             cursor.executemany(
                 """
-                INSERT INTO [tokens] ([voucher], [text]) VALUES (?, ?)
+                INSERT INTO [tokens] ([voucher], [counter], [text]) VALUES (?, ?, ?)
                 """,
                 list(
-                    (voucher, token.token_value)
+                    (voucher, counter, token.token_value)
                     for token
                     in tokens
                 ),
@@ -341,7 +348,7 @@ class VoucherStore(object):
         self._insert_unblinded_tokens(cursor, unblinded_tokens)
 
     @with_cursor
-    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens):
+    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed):
         """
         Store some unblinded tokens received from redemption of a voucher.
 
@@ -354,15 +361,23 @@ class VoucherStore(object):
 
         :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
             store.
+
+        :param bool completed: ``True`` if redemption of this voucher is now
+            complete, ``False`` otherwise.
         """
-        voucher_state = u"redeemed"
+        if  completed:
+            voucher_state = u"redeemed"
+        else:
+            voucher_state = u"pending"
+
         cursor.execute(
             """
             UPDATE [vouchers]
             SET [state] = ?
-              , [token-count] = ?
+              , [token-count] = COALESCE([token-count], 0) + ?
               , [finished] = ?
               , [public-key] = ?
+              , [counter] = [counter] + 1
             WHERE [number] = ?
             """,
             (
@@ -864,9 +879,7 @@ class Voucher(object):
     def from_row(cls, row):
         def state_from_row(state, row):
             if state == u"pending":
-                # TODO: The 0 here should be row[3] but I can't write a test
-                # to prove it yet.
-                return Pending(counter=0)
+                return Pending(counter=row[3])
             if state == u"double-spend":
                 return DoubleSpend(
                     parse_datetime(row[0], delimiter=u" "),
diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py
index e3a37eb2023cfe890fb08a7500fdd519e9335bd0..4fa33b13ae1f1001816e269bfd0b49ded10951bb 100644
--- a/src/_zkapauthorizer/schema.py
+++ b/src/_zkapauthorizer/schema.py
@@ -138,4 +138,12 @@ _UPGRADES = {
         ALTER TABLE [vouchers] ADD COLUMN [counter] integer DEFAULT 0
         """,
     ],
+
+    3: [
+        """
+        -- Reference to the counter these tokens go with.
+        ALTER TABLE [tokens] ADD COLUMN [counter] integer NOT NULL DEFAULT 0
+        """,
+
+    ],
 }
diff --git a/src/_zkapauthorizer/tests/matchers.py b/src/_zkapauthorizer/tests/matchers.py
index 09afa8dad01347c3ae08536525fcfca134790728..6c7ab457c04c6971965779bb4445517decf9e933 100644
--- a/src/_zkapauthorizer/tests/matchers.py
+++ b/src/_zkapauthorizer/tests/matchers.py
@@ -104,19 +104,27 @@ class _Returns(Matcher):
         return "Returns({})".format(self.result_matcher)
 
 
+def greater_or_equal(v):
+    """
+    Matches a value greater than or equal to ``v``.
+    """
+    return MatchesAny(GreaterThan(v), Equals(v))
+
+
+def lesser_or_equal(v):
+    """
+    Matches a value less than or equal to ``v``.
+    """
+    return MatchesAny(LessThan(v), Equals(v))
+
+
 def between(low, high):
     """
     Matches a value in the range [low, high].
     """
     return MatchesAll(
-        MatchesAny(
-            Equals(low),
-            GreaterThan(low),
-        ),
-        MatchesAny(
-            Equals(high),
-            LessThan(high),
-        ),
+        greater_or_equal(low),
+        lesser_or_equal(high),
     )
 
 
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 5faf8e74004357f64c009619da005465cd531515..a438fa27ae10bd937f6e30421297da297fc753f6 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -359,6 +359,19 @@ def voucher_counters():
     )
 
 
+def redemption_group_counts():
+    """
+    Build integers which can represent the number of groups in the redemption
+    process.
+    """
+    return integers(
+        min_value=1,
+        # Make this similar to the max_value of voucher_counters since those
+        # counters count through the groups.
+        max_value=256,
+    )
+
+
 def byte_strings(label, length, entropy):
     """
     Build byte strings of the given length with at most the given amount of
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index e2f8934e2ca7a551302cd7f751f12e6589a804c6..a6a96f9c82a4b9615a58b11a68445c094a92506a 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -154,7 +154,7 @@ from .matchers import (
 )
 
 # A small number of tokens to work with in the tests.
-NUM_TOKENS = 10
+NUM_TOKENS = 100
 
 TRANSIENT_ERROR = u"something went wrong, who knows what"
 
@@ -295,6 +295,21 @@ class ResourceTests(TestCase):
         )
 
 
+def maybe_extra_tokens():
+    """
+    Build either ``None`` or a small integer for use in determining a number
+    of additional tokens to create in some tests.
+    """
+    # We might want to have some unblinded tokens or we might not.
+    return one_of(
+        just(None),
+        # If we do, we can't have fewer than the number of redemption groups
+        # which we don't know until we're further inside the test.  So supply
+        # an amount to add to that, in the case where we have tokens at all.
+        integers(min_value=0, max_value=100),
+    )
+
+
 class UnblindedTokenTests(TestCase):
     """
     Tests relating to ``/unblinded-token`` as implemented by the
@@ -353,8 +368,12 @@ class UnblindedTokenTests(TestCase):
             )),
         )
 
-    @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100))
-    def test_get(self, get_config, voucher, num_tokens):
+    @given(
+        tahoe_configs(),
+        vouchers(),
+        maybe_extra_tokens(),
+    )
+    def test_get(self, get_config, voucher, extra_tokens):
         """
         When the unblinded token collection receives a **GET**, the response is
         the total number of unblinded tokens in the system, the unblinded
@@ -364,8 +383,10 @@ class UnblindedTokenTests(TestCase):
         tempdir = self.useFixture(TempDir())
         config = get_config(tempdir.join(b"tahoe"), b"tub.port")
         root = root_from_config(config, datetime.now)
-
-        if num_tokens:
+        if extra_tokens is None:
+            num_tokens = 0
+        else:
+            num_tokens = root.controller.num_redemption_groups + extra_tokens
             # Put in a number of tokens with which to test.
             redeeming = root.controller.redeem(voucher, num_tokens)
             # Make sure the operation completed before proceeding.
@@ -388,8 +409,8 @@ class UnblindedTokenTests(TestCase):
             succeeded_with_unblinded_tokens(num_tokens, num_tokens),
         )
 
-    @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100), integers(min_value=0))
-    def test_get_limit(self, get_config, voucher, num_tokens, limit):
+    @given(tahoe_configs(), vouchers(), maybe_extra_tokens(), integers(min_value=0))
+    def test_get_limit(self, get_config, voucher, extra_tokens, limit):
         """
         When the unblinded token collection receives a **GET** with a **limit**
         query argument, it returns no more unblinded tokens than indicated by
@@ -399,7 +420,10 @@ class UnblindedTokenTests(TestCase):
         config = get_config(tempdir.join(b"tahoe"), b"tub.port")
         root = root_from_config(config, datetime.now)
 
-        if num_tokens:
+        if extra_tokens is None:
+            num_tokens = 0
+        else:
+            num_tokens = root.controller.num_redemption_groups + extra_tokens
             # Put in a number of tokens with which to test.
             redeeming = root.controller.redeem(voucher, num_tokens)
             # Make sure the operation completed before proceeding.
@@ -419,11 +443,14 @@ class UnblindedTokenTests(TestCase):
         )
         self.assertThat(
             requesting,
-            succeeded_with_unblinded_tokens(num_tokens, min(num_tokens, limit)),
+            succeeded_with_unblinded_tokens(
+                num_tokens,
+                min(num_tokens, limit),
+            ),
         )
 
-    @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100), text(max_size=64))
-    def test_get_position(self, get_config, voucher, num_tokens, position):
+    @given(tahoe_configs(), vouchers(), maybe_extra_tokens(), text(max_size=64))
+    def test_get_position(self, get_config, voucher, extra_tokens, position):
         """
         When the unblinded token collection receives a **GET** with a **position**
         query argument, it returns all unblinded tokens which sort greater
@@ -433,7 +460,10 @@ class UnblindedTokenTests(TestCase):
         config = get_config(tempdir.join(b"tahoe"), b"tub.port")
         root = root_from_config(config, datetime.now)
 
-        if num_tokens:
+        if extra_tokens is None:
+            num_tokens = 0
+        else:
+            num_tokens = root.controller.num_redemption_groups + extra_tokens
             # Put in a number of tokens with which to test.
             redeeming = root.controller.redeem(voucher, num_tokens)
             # Make sure the operation completed before proceeding.
@@ -467,8 +497,8 @@ class UnblindedTokenTests(TestCase):
             ),
         )
 
-    @given(tahoe_configs(), vouchers(), integers(min_value=1, max_value=100))
-    def test_get_order_matches_use_order(self, get_config, voucher, num_tokens):
+    @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100))
+    def test_get_order_matches_use_order(self, get_config, voucher, extra_tokens):
         """
         The first unblinded token returned in a response to a **GET** request is
         the first token to be used to authorize a storage request.
@@ -499,6 +529,8 @@ class UnblindedTokenTests(TestCase):
         config = get_config(tempdir.join(b"tahoe"), b"tub.port")
         root = root_from_config(config, datetime.now)
 
+        num_tokens = root.controller.num_redemption_groups + extra_tokens
+
         # Put in a number of tokens with which to test.
         redeeming = root.controller.redeem(voucher, num_tokens)
         # Make sure the operation completed before proceeding.
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 9d97ce8b75054deac17e99272ad30ddbeba4996d..77d7e90abccd49ee5fb07e9afc148689844377c5 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -18,6 +18,7 @@ Tests for ``_zkapauthorizer.controller``.
 
 from __future__ import (
     absolute_import,
+    division,
 )
 
 from json import (
@@ -41,6 +42,7 @@ from testtools.content import (
     text_content,
 )
 from testtools.matchers import (
+    Always,
     Equals,
     MatchesAll,
     AllMatch,
@@ -51,11 +53,13 @@ from testtools.matchers import (
 )
 from testtools.twistedsupport import (
     succeeded,
+    has_no_result,
     failed,
 )
 
 from hypothesis import (
     given,
+    assume,
 )
 from hypothesis.strategies import (
     integers,
@@ -104,9 +108,12 @@ from ..controller import (
     DoubleSpendRedeemer,
     UnpaidRedeemer,
     RistrettoRedeemer,
+    IndexedRedeemer,
+    RecordingRedeemer,
     PaymentController,
     AlreadySpent,
     Unpaid,
+    token_count_for_group,
 )
 
 from ..model import (
@@ -123,20 +130,126 @@ from .strategies import (
     vouchers,
     voucher_objects,
     voucher_counters,
+    redemption_group_counts,
     dummy_ristretto_keys,
     clocks,
 )
 from .matchers import (
     Provides,
+    raises,
+    between,
 )
 from .fixtures import (
     TemporaryVoucherStore,
 )
 
+
+class TokenCountForGroupTests(TestCase):
+    """
+    Tests for ``token_count_for_group``.
+    """
+    @given(
+        integers(),
+        integers(),
+        integers(),
+    )
+    def test_out_of_bounds(self, num_groups, total_tokens, group_number):
+        """
+        If there are not enough tokens so that each group gets at least one or if
+        the indicated group number does properly identify a group from the
+        range then ``ValueError`` is raised.
+        """
+        assume(
+            group_number < 0 or
+            group_number >= num_groups or
+            total_tokens < num_groups
+        )
+        self.assertThat(
+            lambda: token_count_for_group(num_groups, total_tokens, group_number),
+            raises(ValueError),
+        )
+
+    @given(
+        redemption_group_counts(),
+        integers(min_value=0),
+    )
+    def test_sum(self, num_groups, extra_tokens):
+        """
+        The sum of the token count for all groups equals the requested total
+        tokens.
+        """
+        total_tokens = num_groups + extra_tokens
+        self.assertThat(
+            sum(
+                token_count_for_group(num_groups, total_tokens, group_number)
+                for group_number
+                in range(num_groups)
+            ),
+            Equals(total_tokens),
+        )
+
+    @given(
+        redemption_group_counts(),
+        integers(min_value=0),
+    )
+    def test_well_distributed(self, num_groups, extra_tokens):
+        """
+        Tokens are distributed roughly evenly across all group numbers.
+        """
+        total_tokens = num_groups + extra_tokens
+
+        lower_bound = total_tokens // num_groups
+        upper_bound = total_tokens // num_groups + 1
+
+        self.assertThat(
+            list(
+                token_count_for_group(num_groups, total_tokens, group_number)
+                for group_number
+                in range(num_groups)
+            ),
+            AllMatch(between(lower_bound, upper_bound)),
+        )
+
+
 class PaymentControllerTests(TestCase):
     """
     Tests for ``PaymentController``.
     """
+    @given(tahoe_configs(), datetimes(), vouchers())
+    def test_should_not_redeem(self, get_config, now, voucher):
+        """
+        ``PaymentController.redeem`` raises ``ValueError`` if passed a voucher in
+        a state when redemption should not be started.
+        """
+        store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
+        controller = PaymentController(
+            store,
+            DummyRedeemer(),
+            default_token_count=100,
+        )
+
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
+        )
+
+        # Sanity check.  It should be redeemed now.
+        voucher_obj = controller.get_voucher(voucher)
+        self.assertThat(
+            voucher_obj.state.should_start_redemption(),
+            Equals(False),
+        )
+
+        self.assertThat(
+            controller.redeem(voucher),
+            failed(
+                AfterPreprocessing(
+                    lambda f: f.type,
+                    Equals(ValueError),
+                ),
+            ),
+        )
+
     @given(tahoe_configs(), datetimes(), vouchers())
     def test_not_redeemed_while_redeeming(self, get_config, now, voucher):
         """
@@ -147,9 +260,12 @@ class PaymentControllerTests(TestCase):
         controller = PaymentController(
             store,
             NonRedeemer(),
-            default_token_count=10,
+            default_token_count=100,
+        )
+        self.assertThat(
+            controller.redeem(voucher),
+            has_no_result(),
         )
-        controller.redeem(voucher)
 
         persisted_voucher = store.get(voucher)
         self.assertThat(
@@ -157,29 +273,157 @@ class PaymentControllerTests(TestCase):
             Equals(model_Pending(counter=0)),
         )
 
-    @given(tahoe_configs(), datetimes(), vouchers())
-    def test_redeeming(self, get_config, now, voucher):
+    @given(tahoe_configs(), datetimes(), vouchers(), voucher_counters())
+    def test_redeeming(self, get_config, now, voucher, num_successes):
         """
         A ``Voucher`` is marked redeeming while ``IRedeemer.redeem`` is actively
-        working on redeeming it.
+        working on redeeming it with a counter value that reflects the number
+        of successful partial redemptions so far completed.
         """
+        # The voucher counter can be zero (no tries yet succeeded).  We want
+        # at least *one* run through so we'll bump this up to be sure we get
+        # that.
+        counter = num_successes + 1
+        redeemer = IndexedRedeemer(
+            [DummyRedeemer()] * num_successes +
+            [NonRedeemer()],
+        )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         controller = PaymentController(
             store,
-            NonRedeemer(),
-            default_token_count=10,
+            redeemer,
+            # This will give us one ZKAP per attempt.
+            default_token_count=counter,
+            # Require more success than we're going to get so it doesn't
+            # finish.
+            num_redemption_groups=counter,
+        )
+
+        self.assertThat(
+            controller.redeem(voucher),
+            has_no_result(),
         )
-        controller.redeem(voucher)
 
         controller_voucher = controller.get_voucher(voucher)
         self.assertThat(
             controller_voucher.state,
             Equals(model_Redeeming(
                 started=now,
-                counter=0,
+                counter=num_successes,
             )),
         )
 
+    @given(
+        tahoe_configs(),
+        datetimes(),
+        vouchers(),
+        voucher_counters(),
+        voucher_counters().map(lambda v: v + 1),
+    )
+    def test_restart_redeeming(self, get_config, now, voucher, before_restart, after_restart):
+        """
+        If some redemption groups for a voucher have succeeded but the process is
+        interrupted, redemption begins at the first incomplete redemption
+        group when it resumes.
+
+        :parm int before_restart: The number of redemption groups which will
+            be allowed to succeed before making the redeemer hang.  Redemption
+            will then be required to begin again from only database state.
+
+        :param int after_restart: The number of redemption groups which will
+            be required to succeed after restarting the process.
+        """
+        # Divide redemption into some groups that will succeed before a
+        # restart and some that must succeed after a restart.
+        num_redemption_groups = before_restart + after_restart
+        # Give it enough tokens so each group can have one.
+        num_tokens = num_redemption_groups
+
+        store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
+
+        def first_try():
+            controller = PaymentController(
+                store,
+                # It will let `before_restart` attempts succeed before hanging.
+                IndexedRedeemer(
+                    [DummyRedeemer()] * before_restart +
+                    [NonRedeemer()] * after_restart,
+                ),
+                default_token_count=num_tokens,
+                num_redemption_groups=num_redemption_groups,
+            )
+            self.assertThat(
+                controller.redeem(voucher),
+                has_no_result(),
+            )
+
+        def second_try():
+            # The controller will find the voucher in the voucher store and
+            # restart redemption on its own.
+            return PaymentController(
+                store,
+                # It will succeed only for the higher counter values which did
+                # not succeed or did not get started on the first try.
+                IndexedRedeemer(
+                    [NonRedeemer()] * before_restart +
+                    [DummyRedeemer()] * after_restart,
+                ),
+                # TODO: It shouldn't need a default token count.  It should
+                # respect whatever was given on the first redemption attempt.
+                #
+                # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/93
+                default_token_count=num_tokens,
+                # The number of redemption groups must not change for
+                # redemption of a particular voucher.
+                num_redemption_groups=num_redemption_groups,
+            )
+
+        first_try()
+        controller = second_try()
+
+        persisted_voucher = controller.get_voucher(voucher)
+        self.assertThat(
+            persisted_voucher.state,
+            Equals(
+                model_Redeemed(
+                    finished=now,
+                    token_count=num_tokens,
+                    public_key=None,
+                ),
+            ),
+        )
+
+
+    @given(tahoe_configs(), datetimes(), vouchers(), voucher_counters(), integers(min_value=0, max_value=100))
+    def test_stop_redeeming_on_error(self, get_config, now, voucher, counter, extra_tokens):
+        """
+        If an error is encountered on one of the redemption attempts performed by
+        ``IRedeemer.redeem``, the effort is suspended until the normal retry
+        logic activates.
+        """
+        num_redemption_groups = counter + 1
+        num_tokens = num_redemption_groups + extra_tokens
+        redeemer = RecordingRedeemer(UnpaidRedeemer())
+
+        store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
+        controller = PaymentController(
+            store,
+            redeemer,
+            default_token_count=num_tokens,
+            num_redemption_groups=num_redemption_groups,
+        )
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
+        )
+        self.assertThat(
+            redeemer.redemptions,
+            AfterPreprocessing(
+                len,
+                Equals(1),
+            ),
+        )
+
     @given(tahoe_configs(), dummy_ristretto_keys(), datetimes(), vouchers())
     def test_redeemed_after_redeeming(self, get_config, public_key, now, voucher):
         """
@@ -189,16 +433,19 @@ class PaymentControllerTests(TestCase):
         controller = PaymentController(
             store,
             DummyRedeemer(public_key),
-            default_token_count=10,
+            default_token_count=100,
+        )
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
         )
-        controller.redeem(voucher)
 
         persisted_voucher = store.get(voucher)
         self.assertThat(
             persisted_voucher.state,
             Equals(model_Redeemed(
                 finished=now,
-                token_count=10,
+                token_count=100,
                 public_key=public_key,
             )),
         )
@@ -213,9 +460,12 @@ class PaymentControllerTests(TestCase):
         controller = PaymentController(
             store,
             DoubleSpendRedeemer(),
-            default_token_count=10,
+            default_token_count=100,
+        )
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
         )
-        controller.redeem(voucher)
 
         persisted_voucher = store.get(voucher)
         self.assertThat(
@@ -239,9 +489,12 @@ class PaymentControllerTests(TestCase):
         unpaid_controller = PaymentController(
             store,
             UnpaidRedeemer(),
-            default_token_count=10,
+            default_token_count=100,
+        )
+        self.assertThat(
+            unpaid_controller.redeem(voucher),
+            succeeded(Always()),
         )
-        unpaid_controller.redeem(voucher)
 
         # Make sure we got where we wanted.
         self.assertThat(
@@ -255,7 +508,7 @@ class PaymentControllerTests(TestCase):
         success_controller = PaymentController(
             store,
             DummyRedeemer(),
-            default_token_count=10,
+            default_token_count=100,
         )
 
         self.assertThat(
@@ -284,10 +537,13 @@ class PaymentControllerTests(TestCase):
         controller = PaymentController(
             store,
             UnpaidRedeemer(),
-            default_token_count=10,
+            default_token_count=100,
             clock=clock,
         )
-        controller.redeem(voucher)
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
+        )
         # It fails this time.
         self.assertThat(
             controller.get_voucher(voucher).state,
@@ -343,7 +599,7 @@ class RistrettoRedeemerTests(TestCase):
         issuer = RistrettoRedemption(signing_key)
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
-        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens)
         d = redeemer.redeemWithCounter(
             voucher,
             counter,
@@ -366,18 +622,19 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
-    def test_redemption_denied_alreadyspent(self, voucher, counter, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100))
+    def test_redemption_denied_alreadyspent(self, voucher, counter, extra_tokens):
         """
         If the issuer declines to allow the voucher to be redeemed and gives a
         reason that the voucher has already been spent, ``RistrettoRedeem``
         returns a ``Deferred`` that fires with a ``Failure`` wrapping
         ``AlreadySpent``.
         """
+        num_tokens = counter + extra_tokens
         issuer = AlreadySpentRedemption()
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
-        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens)
         d = redeemer.redeemWithCounter(
             voucher,
             counter,
@@ -393,18 +650,19 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
-    def test_redemption_denied_unpaid(self, voucher, counter, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100))
+    def test_redemption_denied_unpaid(self, voucher, counter, extra_tokens):
         """
         If the issuer declines to allow the voucher to be redeemed and gives a
         reason that the voucher has not been paid for, ``RistrettoRedeem``
         returns a ``Deferred`` that fires with a ``Failure`` wrapping
         ``Unpaid``.
         """
+        num_tokens = counter + extra_tokens
         issuer = UnpaidRedemption()
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
-        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens)
         d = redeemer.redeemWithCounter(
             voucher,
             counter,
@@ -420,13 +678,14 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
-    def test_bad_ristretto_redemption(self, voucher, counter, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100))
+    def test_bad_ristretto_redemption(self, voucher, counter, extra_tokens):
         """
         If the issuer returns a successful result with an invalid proof then
         ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a
         ``Failure`` wrapping ``SecurityException``.
         """
+        num_tokens = counter + extra_tokens
         signing_key = random_signing_key()
         issuer = RistrettoRedemption(signing_key)
 
@@ -437,7 +696,7 @@ class RistrettoRedeemerTests(TestCase):
 
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
-        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens)
         d = redeemer.redeemWithCounter(
             voucher,
             counter,
@@ -454,20 +713,20 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
-    def test_ristretto_pass_construction(self, voucher, counter, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=0, max_value=100))
+    def test_ristretto_pass_construction(self, voucher, counter, extra_tokens):
         """
         The passes constructed using unblinded tokens and messages pass the
         Ristretto verification check.
         """
+        num_tokens = counter + extra_tokens
         message = b"hello world"
-
         signing_key = random_signing_key()
         issuer = RistrettoRedemption(signing_key)
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
 
-        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, counter, num_tokens)
         d = redeemer.redeemWithCounter(
             voucher,
             counter,
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 13d3dcf85e4192b1b2417f6a0a67117166f980af..5bd3e3145d12b90a67ef27ce4aaa7dc382864ef4 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -57,6 +57,7 @@ from hypothesis import (
 
 from hypothesis.strategies import (
     data,
+    booleans,
     lists,
     tuples,
     datetimes,
@@ -84,6 +85,7 @@ from .strategies import (
     tahoe_configs,
     vouchers,
     voucher_objects,
+    voucher_counters,
     random_tokens,
     unblinded_tokens,
     posix_safe_datetimes,
@@ -120,7 +122,7 @@ class VoucherStoreTests(TestCase):
         previously added to the store with ``VoucherStore.add``.
         """
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        store.add(voucher, lambda: tokens)
+        store.add(voucher, 0, lambda: tokens)
         self.assertThat(
             store.get(voucher),
             MatchesStructure(
@@ -130,6 +132,39 @@ class VoucherStoreTests(TestCase):
             ),
         )
 
+    @given(
+        tahoe_configs(),
+        vouchers(),
+        lists(voucher_counters(), unique=True, min_size=2, max_size=2),
+        lists(random_tokens(), min_size=2, unique=True),
+        datetimes(),
+    )
+    def test_add_with_distinct_counters(self, get_config, voucher, counters, tokens, now):
+        """
+        ``VoucherStore.add`` adds new tokens to the store when passed the same
+        voucher but a different counter value.
+        """
+        counter_a = counters[0]
+        counter_b = counters[1]
+        tokens_a = tokens[:len(tokens) / 2]
+        tokens_b = tokens[len(tokens) / 2:]
+
+        store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
+        added_tokens_a = store.add(voucher, counter_a, lambda: tokens_a)
+        added_tokens_b = store.add(voucher, counter_b, lambda: tokens_b)
+
+        self.assertThat(
+            store.get(voucher),
+            MatchesStructure(
+                number=Equals(voucher),
+                state=Equals(Pending(counter=0)),
+                created=Equals(now),
+            ),
+        )
+
+        self.assertThat(tokens_a, Equals(added_tokens_a))
+        self.assertThat(tokens_b, Equals(added_tokens_b))
+
     @given(tahoe_configs(), vouchers(), datetimes(), lists(random_tokens(), unique=True))
     def test_add_idempotent(self, get_config, voucher, now, tokens):
         """
@@ -137,8 +172,8 @@ class VoucherStoreTests(TestCase):
         in the same state as a single call.
         """
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        first_tokens = store.add(voucher, lambda: tokens)
-        second_tokens = store.add(voucher, lambda: [])
+        first_tokens = store.add(voucher, 0, lambda: tokens)
+        second_tokens = store.add(voucher, 0, lambda: [])
         self.assertThat(
             store.get(voucher),
             MatchesStructure(
@@ -164,7 +199,7 @@ class VoucherStoreTests(TestCase):
         """
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         for voucher in vouchers:
-            store.add(voucher, lambda: [])
+            store.add(voucher, 0, lambda: [])
 
         self.assertThat(
             store.list(),
@@ -302,11 +337,12 @@ class VoucherStoreTests(TestCase):
 
         # Put some tokens in it that we can backup and extract
         random_tokens, unblinded_tokens = paired_tokens(data, integers(min_value=1, max_value=5))
-        store.add(voucher_value, lambda: random_tokens)
+        store.add(voucher_value, 0, lambda: random_tokens)
         store.insert_unblinded_tokens_for_voucher(
             voucher_value,
             public_key,
             unblinded_tokens,
+            completed=data.draw(booleans()),
         )
 
         backed_up_tokens = store.backup()[u"unblinded-tokens"]
@@ -450,8 +486,9 @@ class UnblindedTokenStoreTests(TestCase):
         vouchers(),
         dummy_ristretto_keys(),
         lists(unblinded_tokens(), unique=True),
+        booleans(),
     )
-    def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens):
+    def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens, completed):
         """
         Unblinded tokens for a voucher which has not been added to the store cannot be inserted.
         """
@@ -461,6 +498,7 @@ class UnblindedTokenStoreTests(TestCase):
                 voucher_value,
                 public_key,
                 unblinded_tokens,
+                completed,
             ),
             raises(ValueError),
         )
@@ -470,16 +508,17 @@ class UnblindedTokenStoreTests(TestCase):
         datetimes(),
         vouchers(),
         dummy_ristretto_keys(),
+        booleans(),
         data(),
     )
-    def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, data):
+    def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, completed, data):
         """
         Unblinded tokens that are added to the store can later be retrieved.
         """
         random_tokens, unblinded_tokens = paired_tokens(data)
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        store.add(voucher_value, lambda: random_tokens)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens)
+        store.add(voucher_value, 0, lambda: random_tokens)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed)
         retrieved_tokens = store.extract_unblinded_tokens(len(random_tokens))
 
         self.expectThat(
@@ -524,8 +563,8 @@ class UnblindedTokenStoreTests(TestCase):
         )
 
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        store.add(voucher_value, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
+        store.add(voucher_value, 0, lambda: random)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
         loaded_voucher = store.get(voucher_value)
         self.assertThat(
             loaded_voucher,
@@ -550,7 +589,7 @@ class UnblindedTokenStoreTests(TestCase):
         such.
         """
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        store.add(voucher_value, lambda: random_tokens)
+        store.add(voucher_value, 0, lambda: random_tokens)
         store.mark_voucher_double_spent(voucher_value)
         voucher = store.get(voucher_value)
         self.assertThat(
@@ -591,8 +630,8 @@ class UnblindedTokenStoreTests(TestCase):
             ),
         )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        store.add(voucher_value, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
+        store.add(voucher_value, 0, lambda: random)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
         self.assertThat(
             lambda: store.mark_voucher_double_spent(voucher_value),
             raises(ValueError),
@@ -618,11 +657,12 @@ class UnblindedTokenStoreTests(TestCase):
         datetimes(),
         vouchers(),
         dummy_ristretto_keys(),
+        booleans(),
         integers(min_value=1, max_value=100),
         integers(min_value=1),
         data(),
     )
-    def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, num_tokens, extra, data):
+    def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, completed, num_tokens, extra, data):
         """
         ``extract_unblinded_tokens`` raises ``NotEnoughTokens`` if ``count`` is
         greater than the number of unblinded tokens in the store.
@@ -644,8 +684,8 @@ class UnblindedTokenStoreTests(TestCase):
             ),
         )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
-        store.add(voucher_value, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
+        store.add(voucher_value, 0, lambda: random)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed)
 
         self.assertThat(
             lambda: store.extract_unblinded_tokens(num_tokens + extra),
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index 41a532dce7c3b1a69199b4a2cea5cc488065c896..ebd714863a3ab95a590826698001ba4cac469965 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -439,12 +439,16 @@ class ClientPluginTests(TestCase):
         )
 
         store = VoucherStore.from_node_config(node_config, lambda: now)
+        # Give it enough for the allocate_buckets call below.
         expected_pass_cost = required_passes(store.pass_value, [size] * len(sharenums))
+        # And few enough redemption groups given the number of tokens.
+        num_redemption_groups = expected_pass_cost
+
         controller = PaymentController(
             store,
             DummyRedeemer(),
-            # Give it enough for the allocate_buckets call below.
-            expected_pass_cost,
+            default_token_count=expected_pass_cost,
+            num_redemption_groups=num_redemption_groups,
         )
         # Get a token inserted into the store.
         redeeming = controller.redeem(voucher)