diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index b16473f367d5be09920f3f399c33ecdba1fc95d7..1bc586e1d65f7ba536a0dd0f7e96c33facaa2650 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -114,6 +114,21 @@ class Unpaid(Exception):
     """
 
 
+@attr.s
+class RedemptionResult(object):
+    """
+    Contain the results of an attempt to redeem a voucher for ZKAP material.
+
+    :ivar list[UnblindedToken] unblinded_tokens: The tokens which resulted
+        from the redemption.
+
+    :ivar unicode public_key: The public key which the server proved was
+        involved in the redemption process.
+    """
+    unblinded_tokens = attr.ib()
+    public_key = attr.ib()
+
+
 class IRedeemer(Interface):
     """
     An ``IRedeemer`` can exchange a voucher for one or more passes.
@@ -150,12 +165,11 @@ class IRedeemer(Interface):
         :param list[RandomToken] random_tokens: The random tokens to use in
             the redemption process.
 
-        :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.  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.
+        :return: A ``Deferred`` which fires with a ``RedemptionResult``
+            instance or which fails with any error 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):
@@ -290,6 +304,8 @@ class DummyRedeemer(object):
     really redeeming them, it makes up some fake ZKAPs and pretends those are
     the result.
     """
+    _public_key = attr.ib(default=None)
+
     @classmethod
     def make(cls, section_name, node_config, announcement, reactor):
         return cls()
@@ -311,10 +327,13 @@ class DummyRedeemer(object):
             unblinded_value = random_value + b"x" * (96 - len(random_value))
             return UnblindedToken(b64encode(unblinded_value).decode("ascii"))
         return succeed(
-            list(
-                dummy_unblinded_token(token)
-                for token
-                in random_tokens
+            RedemptionResult(
+                list(
+                    dummy_unblinded_token(token)
+                    for token
+                    in random_tokens
+                ),
+                self._public_key,
             ),
         )
 
@@ -483,10 +502,14 @@ class RistrettoRedeemer(object):
                 public_key,
             )
         self._log.info("Validated proof")
-        returnValue(list(
+        unblinded_tokens = list(
             UnblindedToken(token.encode_base64().decode("ascii"))
             for token
             in clients_unblinded_tokens
+        )
+        returnValue(RedemptionResult(
+            unblinded_tokens,
+            marshaled_public_key,
         ))
 
     def tokens_to_passes(self, message, unblinded_tokens):
@@ -677,14 +700,21 @@ class PaymentController(object):
         tokens = self._get_random_tokens_for_voucher(voucher, num_tokens)
         return self._perform_redeem(voucher, tokens)
 
-    def _redeemSuccess(self, voucher, unblinded_tokens):
+    def _redeemSuccess(self, voucher, 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
         passes later).
         """
-        self._log.info("Inserting redeemed unblinded tokens for a voucher ({voucher}).", voucher=voucher)
-        self.store.insert_unblinded_tokens_for_voucher(voucher, unblinded_tokens)
+        self._log.info(
+            "Inserting redeemed unblinded tokens for a voucher ({voucher}).",
+            voucher=voucher,
+        )
+        self.store.insert_unblinded_tokens_for_voucher(
+            voucher,
+            result.public_key,
+            result.unblinded_tokens,
+        )
 
     def _redeemFailure(self, voucher, reason):
         if reason.check(AlreadySpent):
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index a208fbc9cfc6e1db9a7a1b7bd8594e4da264d5fa..8691fface49804e256e2a003930cb9035c155da7 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -61,6 +61,12 @@ from .storage_common import (
     required_passes,
 )
 
+from .schema import (
+    get_schema_version,
+    get_schema_upgrades,
+    run_schema_upgrades,
+)
+
 
 class ILeaseMaintenanceObserver(Interface):
     """
@@ -88,13 +94,9 @@ class StoreOpenError(Exception):
         self.reason = reason
 
 
-class SchemaError(TypeError):
-    pass
-
-
 CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3"
 
-def open_and_initialize(path, required_schema_version, connect=None):
+def open_and_initialize(path, connect=None):
     """
     Open a SQLite3 database for use as a voucher store.
 
@@ -103,13 +105,6 @@ def open_and_initialize(path, required_schema_version, connect=None):
 
     :param FilePath path: The location of the SQLite3 database file.
 
-    :param int required_schema_version: The schema version which must be
-        present in the database in order for a SQLite3 connection to be
-        returned.
-
-    :raise SchemaError: If the schema in the database does not match the
-        required schema version.
-
     :return: A SQLite3 connection object for the database at the given path.
     """
     if connect is None:
@@ -135,86 +130,9 @@ def open_and_initialize(path, required_schema_version, connect=None):
 
     with conn:
         cursor = conn.cursor()
-        cursor.execute(
-            # This code knows how to create schema version 1.  This is
-            # regardless of what the caller *wants* to find in the database.
-            """
-            CREATE TABLE IF NOT EXISTS [version] AS SELECT 1 AS [version]
-            """
-        )
-        cursor.execute(
-            """
-            SELECT [version] FROM [version]
-            """
-        )
-        [(actual_version,)] = cursor.fetchall()
-        if actual_version != required_schema_version:
-            raise SchemaError(
-                "Unexpected database schema version.  Required {}.  Got {}.".format(
-                    required_schema_version,
-                    actual_version,
-                ),
-            )
-
-        cursor.execute(
-            # A denormalized schema because, for now, it's simpler. :/
-            """
-            CREATE TABLE IF NOT EXISTS [vouchers] (
-                [number] text,
-                [created] text,                     -- An ISO8601 date+time string.
-                [state] text DEFAULT "pending",     -- pending, double-spend, redeemed
-
-                [finished] text DEFAULT NULL,       -- ISO8601 date+time string when
-                                                    -- the current terminal state was entered.
-
-                [token-count] num DEFAULT NULL,     -- Set in the redeemed state to the number
-                                                    -- of tokens received on this voucher's
-                                                    -- redemption.
-
-                PRIMARY KEY([number])
-            )
-            """,
-        )
-        cursor.execute(
-            """
-            CREATE TABLE IF NOT EXISTS [tokens] (
-                [text] text, -- The random string that defines the token.
-                [voucher] text, -- Reference to the voucher these tokens go with.
-
-                PRIMARY KEY([text])
-                FOREIGN KEY([voucher]) REFERENCES [vouchers]([number])
-            )
-            """,
-        )
-        cursor.execute(
-            """
-            CREATE TABLE IF NOT EXISTS [unblinded-tokens] (
-                [token] text, -- The base64 encoded unblinded token.
-
-                PRIMARY KEY([token])
-            )
-            """,
-        )
-        cursor.execute(
-            """
-            CREATE TABLE IF NOT EXISTS [lease-maintenance-spending] (
-                [id] integer, -- A unique identifier for a group of activity.
-                [started] text, -- ISO8601 date+time string when the activity began.
-                [finished] text, -- ISO8601 date+time string when the activity completed (or null).
-
-                -- The number of passes that would be required to renew all
-                -- shares encountered during this activity.  Note that because
-                -- leases on different shares don't necessarily expire at the
-                -- same time this is not necessarily the number of passes
-                -- **actually** used during this activity.  Some shares may
-                -- not have required lease renewal.  Also note that while the
-                -- activity is ongoing this value may change.
-                [count] integer,
-
-                PRIMARY KEY([id])
-            )
-            """,
-        )
+        actual_version = get_schema_version(cursor)
+        schema_upgrades = list(get_schema_upgrades(actual_version))
+        run_schema_upgrades(schema_upgrades, cursor)
     return conn
 
 
@@ -268,7 +186,6 @@ class VoucherStore(object):
         db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
         conn = open_and_initialize(
             db_path,
-            required_schema_version=1,
             connect=connect,
         )
         return cls(
@@ -287,7 +204,7 @@ class VoucherStore(object):
         cursor.execute(
             """
             SELECT
-                [number], [created], [state], [finished], [token-count]
+                [number], [created], [state], [finished], [token-count], [public-key]
             FROM
                 [vouchers]
             WHERE
@@ -371,7 +288,7 @@ class VoucherStore(object):
         cursor.execute(
             """
             SELECT
-                [number], [created], [state], [finished], [token-count]
+                [number], [created], [state], [finished], [token-count], [public-key]
             FROM
                 [vouchers]
             """,
@@ -385,7 +302,7 @@ class VoucherStore(object):
         )
 
     @with_cursor
-    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, unblinded_tokens):
+    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens):
         """
         Store some unblinded tokens.
 
@@ -393,9 +310,32 @@ class VoucherStore(object):
             tokens.  This voucher will be marked as redeemed to indicate it
             has fulfilled its purpose and has no further use for us.
 
+        :param unicode public_key: The encoded public key for the private key
+            which was used to sign these tokens.
+
         :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
             store.
         """
+        voucher_state = u"redeemed"
+        cursor.execute(
+            """
+            UPDATE [vouchers]
+            SET [state] = ?
+              , [token-count] = ?
+              , [finished] = ?
+              , [public-key] = ?
+            WHERE [number] = ?
+            """,
+            (
+                voucher_state,
+                len(unblinded_tokens),
+                self.now(),
+                public_key,
+                voucher,
+            ),
+        )
+        if cursor.rowcount == 0:
+            raise ValueError("Cannot insert tokens for unknown voucher; add voucher first")
         cursor.executemany(
             """
             INSERT INTO [unblinded-tokens] VALUES (?)
@@ -406,16 +346,6 @@ class VoucherStore(object):
                 in unblinded_tokens
             ),
         )
-        cursor.execute(
-            """
-            UPDATE [vouchers]
-            SET [state] = "redeemed"
-              , [token-count] = ?
-              , [finished] = ?
-            WHERE [number] = ?
-            """,
-            (len(unblinded_tokens), self.now(), voucher),
-        )
 
     @with_cursor
     def mark_voucher_double_spent(self, cursor, voucher):
@@ -770,8 +700,20 @@ class Redeeming(object):
 
 @attr.s(frozen=True)
 class Redeemed(object):
+    """
+    The voucher was successfully redeemed.  Associated tokens were retrieved
+    and stored locally.
+
+    :ivar datetime finished: The time when the redemption finished.
+
+    :ivar int token_count: The number of tokens the voucher was redeemed for.
+
+    :ivar unicode public_key: The public part of the key used to sign the
+        tokens for this voucher.
+    """
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
     token_count = attr.ib(validator=attr.validators.instance_of((int, long)))
+    public_key = attr.ib(validator=attr.validators.optional(attr.validators.instance_of(unicode)))
 
     def should_start_redemption(self):
         return False
@@ -781,6 +723,7 @@ class Redeemed(object):
             u"name": u"redeemed",
             u"finished": self.finished.isoformat(),
             u"token-count": self.token_count,
+            u"public-key": self.public_key,
         }
 
 
@@ -847,12 +790,9 @@ class Voucher(object):
     :ivar datetime created: The time at which this voucher was added to this
         node.
 
-    :ivar bool redeemed: ``True`` if this voucher has successfully been
-        redeemed with a payment server, ``False`` otherwise.
-
-    :ivar int token_count: A number of tokens received from the redemption of
-        this voucher if it has been redeemed, ``None`` if it has not been
-        redeemed.
+    :ivar state: An indication of the current state of this voucher.  This is
+        an instance of ``Pending``, ``Redeeming``, ``Redeemed``,
+        ``DoubleSpend``, ``Unpaid``, or ``Error``.
     """
     number = attr.ib(
         validator=attr.validators.and_(
@@ -880,6 +820,7 @@ class Voucher(object):
                 return Redeemed(
                     parse_datetime(row[0], delimiter=u" "),
                     row[1],
+                    row[2],
                 )
             raise ValueError("Unknown voucher state {}".format(state))
 
@@ -920,6 +861,7 @@ class Voucher(object):
             state = Redeemed(
                 finished=parse_datetime(state_json[u"finished"]),
                 token_count=state_json[u"token-count"],
+                public_key=state_json[u"public-key"],
             )
         elif state_name == u"unpaid":
             state = Unpaid(
diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc078c2a102f75decc5a21db4592468bf5e3f1e7
--- /dev/null
+++ b/src/_zkapauthorizer/schema.py
@@ -0,0 +1,134 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import (
+    unicode_literals,
+)
+
+"""
+This module defines the database schema used by the model interface.
+"""
+
+def get_schema_version(cursor):
+    cursor.execute(
+        """
+        CREATE TABLE IF NOT EXISTS [version] AS SELECT 0 AS [version]
+        """
+    )
+    cursor.execute(
+        """
+        SELECT [version] FROM [version]
+        """
+    )
+    [(actual_version,)] = cursor.fetchall()
+    return actual_version
+
+
+def get_schema_upgrades(from_version):
+    """
+    Generate unicode strings containing SQL expressions to alter a schema from
+    ``from_version`` to the latest version.
+
+    :param int from_version: The version of the schema which may require
+        upgrade.
+    """
+    while from_version in _UPGRADES:
+        for upgrade in _UPGRADES[from_version]:
+            yield upgrade
+        yield _INCREMENT_VERSION
+        from_version += 1
+
+
+def run_schema_upgrades(upgrades, cursor):
+    """
+    Apply the given upgrades using the given cursor.
+
+    :param list[unicode] upgrades: The SQL statements to apply for the
+        upgrade.
+
+    :param cursor: A DB-API cursor to use to run the SQL.
+    """
+    for upgrade in upgrades:
+        cursor.execute(upgrade)
+
+
+_INCREMENT_VERSION = (
+    """
+    UPDATE [version]
+    SET [version] = [version] + 1
+    """
+)
+
+# A mapping from old schema versions to lists of unicode strings of SQL to
+# execute against that version of the schema to create the successor schema.
+_UPGRADES = {
+    0: [
+        """
+        CREATE TABLE [vouchers] (
+            [number] text,
+            [created] text,                     -- An ISO8601 date+time string.
+            [state] text DEFAULT "pending",     -- pending, double-spend, redeemed
+
+            [finished] text DEFAULT NULL,       -- ISO8601 date+time string when
+                                                -- the current terminal state was entered.
+
+            [token-count] num DEFAULT NULL,     -- Set in the redeemed state to the number
+                                                -- of tokens received on this voucher's
+                                                -- redemption.
+
+            PRIMARY KEY([number])
+        )
+        """,
+        """
+        CREATE TABLE [tokens] (
+            [text] text, -- The random string that defines the token.
+            [voucher] text, -- Reference to the voucher these tokens go with.
+
+            PRIMARY KEY([text])
+            FOREIGN KEY([voucher]) REFERENCES [vouchers]([number])
+        )
+        """,
+        """
+        CREATE TABLE [unblinded-tokens] (
+            [token] text, -- The base64 encoded unblinded token.
+
+            PRIMARY KEY([token])
+        )
+        """,
+        """
+        CREATE TABLE [lease-maintenance-spending] (
+            [id] integer, -- A unique identifier for a group of activity.
+            [started] text, -- ISO8601 date+time string when the activity began.
+            [finished] text, -- ISO8601 date+time string when the activity completed (or null).
+
+            -- The number of passes that would be required to renew all
+            -- shares encountered during this activity.  Note that because
+            -- leases on different shares don't necessarily expire at the
+            -- same time this is not necessarily the number of passes
+            -- **actually** used during this activity.  Some shares may
+            -- not have required lease renewal.  Also note that while the
+            -- activity is ongoing this value may change.
+            [count] integer,
+
+            PRIMARY KEY([id])
+        )
+        """,
+    ],
+
+    1: [
+        """
+        ALTER TABLE [vouchers] ADD COLUMN [public-key] text
+        """,
+    ],
+}
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 0da7adf84af23d599a7ef8f4556d9d5a28bd2c10..51861424fd41d186d78f2de5539c82dd6b8765eb 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -75,6 +75,8 @@ from ..model import (
     Voucher,
     Pending,
     DoubleSpend,
+    Unpaid,
+    Error,
     Redeemed,
 )
 
@@ -185,6 +187,24 @@ def node_nicknames():
     )
 
 
+def dummy_ristretto_keys():
+    """
+    Build string values which one could imagine might be Ristretto-flavored
+    PrivacyPass signing or public keys.
+
+    They're not really because they're entirely random rather than points on
+    the curve.
+    """
+    return binary(
+        min_size=32,
+        max_size=32,
+    ).map(
+        b64encode,
+    ).map(
+        lambda bs: bs.decode("ascii"),
+    )
+
+
 def server_configurations(signing_key_path):
     """
     Build configuration values for the server-side plugin.
@@ -277,6 +297,16 @@ def vouchers():
         lambda voucher: voucher.decode("ascii"),
     )
 
+def redeemed_states():
+    """
+    Build ``Redeemed`` instances.
+    """
+    return builds(
+        Redeemed,
+        finished=datetimes(),
+        token_count=one_of(integers(min_value=1)),
+        public_key=dummy_ristretto_keys(),
+    )
 
 def voucher_states():
     """
@@ -284,19 +314,24 @@ def voucher_states():
     """
     return one_of(
         just(Pending()),
+        redeemed_states(),
         builds(
             DoubleSpend,
             finished=datetimes(),
         ),
         builds(
-            Redeemed,
+            Unpaid,
+            finished=datetimes(),
+        ),
+        builds(
+            Error,
             finished=datetimes(),
-            token_count=one_of(integers(min_value=1)),
+            details=text(),
         ),
     )
 
 
-def voucher_objects():
+def voucher_objects(states=voucher_states()):
     """
     Build ``Voucher`` instances.
     """
@@ -304,7 +339,7 @@ def voucher_objects():
         Voucher,
         number=vouchers(),
         created=one_of(none(), datetimes()),
-        state=voucher_states(),
+        state=states,
     )
 
 
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index f901d020fa1e23d9b26c141369996560d00a0d44..423f4950de7cadac563576da99496f0c47f18c4b 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -768,6 +768,7 @@ class VoucherTests(TestCase):
                 state=Equals(Redeemed(
                     finished=now,
                     token_count=NUM_TOKENS,
+                    public_key=None,
                 )),
             ),
         )
@@ -913,6 +914,7 @@ class VoucherTests(TestCase):
                         state=Redeemed(
                             finished=now,
                             token_count=NUM_TOKENS,
+                            public_key=None,
                         ),
                     ).marshal()
                     for voucher
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 37997eecd565be664e62887ed89244aec5f1bae7..1c1af180ec01737e5e699da40f4575047273de27 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -116,6 +116,7 @@ from .strategies import (
     tahoe_configs,
     vouchers,
     voucher_objects,
+    dummy_ristretto_keys,
     clocks,
 )
 from .matchers import (
@@ -149,15 +150,15 @@ class PaymentControllerTests(TestCase):
             Equals(model_Pending()),
         )
 
-    @given(tahoe_configs(), datetimes(), vouchers())
-    def test_redeemed_after_redeeming(self, get_config, now, voucher):
+    @given(tahoe_configs(), dummy_ristretto_keys(), datetimes(), vouchers())
+    def test_redeemed_after_redeeming(self, get_config, public_key, now, voucher):
         """
         A ``Voucher`` is marked as redeemed after ``IRedeemer.redeem`` succeeds.
         """
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         controller = PaymentController(
             store,
-            DummyRedeemer(),
+            DummyRedeemer(public_key),
             default_token_count=10,
         )
         controller.redeem(voucher)
@@ -168,6 +169,7 @@ class PaymentControllerTests(TestCase):
             Equals(model_Redeemed(
                 finished=now,
                 token_count=10,
+                public_key=public_key,
             )),
         )
 
@@ -319,11 +321,16 @@ class RistrettoRedeemerTests(TestCase):
         self.assertThat(
             d,
             succeeded(
-                MatchesAll(
-                    AllMatch(
-                        IsInstance(UnblindedToken),
+                MatchesStructure(
+                    unblinded_tokens=MatchesAll(
+                        AllMatch(
+                            IsInstance(UnblindedToken),
+                        ),
+                        HasLength(num_tokens),
+                    ),
+                    public_key=Equals(
+                        PublicKey.from_signing_key(signing_key).encode_base64(),
                     ),
-                    HasLength(num_tokens),
                 ),
             ),
         )
@@ -431,8 +438,8 @@ class RistrettoRedeemerTests(TestCase):
             voucher,
             random_tokens,
         )
-        def unblinded_tokens_to_passes(unblinded_tokens):
-            passes = redeemer.tokens_to_passes(message, unblinded_tokens)
+        def unblinded_tokens_to_passes(result):
+            passes = redeemer.tokens_to_passes(message, result.unblinded_tokens)
             return passes
         d.addCallback(unblinded_tokens_to_passes)
 
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 00dc6eca2395628b38bc56c1d1a7ef7a20ddb321..c4885ed96367e4227a46dc080e2f66df1895d780 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -68,16 +68,12 @@ from hypothesis.strategies import (
 from twisted.python.runtime import (
     platform,
 )
-from twisted.python.filepath import (
-    FilePath,
-)
 
 from ..storage_common import (
     BYTES_PER_PASS,
 )
 
 from ..model import (
-    SchemaError,
     StoreOpenError,
     VoucherStore,
     Voucher,
@@ -85,7 +81,6 @@ from ..model import (
     DoubleSpend,
     Redeemed,
     LeaseMaintenanceActivity,
-    open_and_initialize,
     memory_connect,
 )
 
@@ -96,6 +91,7 @@ from .strategies import (
     random_tokens,
     unblinded_tokens,
     posix_safe_datetimes,
+    dummy_ristretto_keys,
 )
 from .fixtures import (
     TemporaryVoucherStore,
@@ -106,22 +102,6 @@ class VoucherStoreTests(TestCase):
     """
     Tests for ``VoucherStore``.
     """
-    def test_create_mismatched_schema(self):
-        """
-        ``open_and_initialize`` raises ``SchemaError`` if asked for a database
-        with a schema version other than it can create.
-        """
-        tempdir = self.useFixture(TempDir())
-        dbpath = tempdir.join(b"db.sqlite3")
-        self.assertThat(
-            lambda: open_and_initialize(
-                FilePath(dbpath),
-                required_schema_version=100,
-            ),
-            raises(SchemaError),
-        )
-
-
     @given(tahoe_configs(), datetimes(), vouchers())
     def test_get_missing(self, get_config, now, voucher):
         """
@@ -348,19 +328,71 @@ class VoucherTests(TestCase):
         )
 
 
+def paired_tokens(data):
+    """
+    Draw two lists of the same length, one of random tokens and one of
+    unblinded tokens.
+
+    :rtype: ([RandomTokens], [UnblindedTokens])
+    """
+    num_tokens = data.draw(integers(min_value=1, max_value=1000))
+    r = data.draw(lists(
+        random_tokens(),
+        min_size=num_tokens,
+        max_size=num_tokens,
+        unique=True,
+    ))
+    u = data.draw(lists(
+        unblinded_tokens(),
+        min_size=num_tokens,
+        max_size=num_tokens,
+        unique=True,
+    ))
+    return r, u
+
+
 class UnblindedTokenStoreTests(TestCase):
     """
     Tests for ``UnblindedToken``-related functionality of ``VoucherStore``.
     """
-    @given(tahoe_configs(), datetimes(), vouchers(), lists(unblinded_tokens(), unique=True))
-    def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, tokens):
+    @given(
+        tahoe_configs(),
+        datetimes(),
+        vouchers(),
+        dummy_ristretto_keys(),
+        lists(unblinded_tokens(), unique=True),
+    )
+    def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens):
+        """
+        Unblinded tokens for a voucher which has not been added to the store cannot be inserted.
+        """
+        store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
+        try:
+            result = store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens)
+        except ValueError:
+            pass
+        except Exception as e:
+            self.fail("insert_unblinded_tokens_for_voucher raised the wrong exception: {}".format(e))
+        else:
+            self.fail("insert_unblinded_tokens_for_voucher didn't raise, returned: {}".format(result))
+
+    @given(
+        tahoe_configs(),
+        datetimes(),
+        vouchers(),
+        dummy_ristretto_keys(),
+        data(),
+    )
+    def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, 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.insert_unblinded_tokens_for_voucher(voucher_value, tokens)
-        retrieved_tokens = store.extract_unblinded_tokens(len(tokens))
-        self.expectThat(tokens, AfterPreprocessing(sorted, Equals(retrieved_tokens)))
+        store.add(voucher_value, lambda: random_tokens)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens)
+        retrieved_tokens = store.extract_unblinded_tokens(len(random_tokens))
+        self.expectThat(unblinded_tokens, AfterPreprocessing(sorted, Equals(retrieved_tokens)))
 
         # After extraction, the unblinded tokens are no longer available.
         more_unblinded_tokens = store.extract_unblinded_tokens(1)
@@ -370,10 +402,11 @@ class UnblindedTokenStoreTests(TestCase):
         tahoe_configs(),
         datetimes(),
         vouchers(),
+        dummy_ristretto_keys(),
         integers(min_value=1, max_value=100),
         data(),
     )
-    def test_mark_vouchers_redeemed(self, get_config, now, voucher_value, num_tokens, data):
+    def test_mark_vouchers_redeemed(self, get_config, now, voucher_value, public_key, num_tokens, data):
         """
         The voucher for unblinded tokens that are added to the store is marked as
         redeemed.
@@ -397,7 +430,7 @@ 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, unblinded)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
         loaded_voucher = store.get(voucher_value)
         self.assertThat(
             loaded_voucher,
@@ -405,6 +438,7 @@ class UnblindedTokenStoreTests(TestCase):
                 state=Equals(Redeemed(
                     finished=now,
                     token_count=num_tokens,
+                    public_key=public_key,
                 )),
             ),
         )
@@ -437,10 +471,11 @@ class UnblindedTokenStoreTests(TestCase):
         tahoe_configs(),
         datetimes(),
         vouchers(),
+        dummy_ristretto_keys(),
         integers(min_value=1, max_value=100),
         data(),
     )
-    def test_mark_spent_vouchers_double_spent(self, get_config, now, voucher_value, num_tokens, data):
+    def test_mark_spent_vouchers_double_spent(self, get_config, now, voucher_value, public_key, num_tokens, data):
         """
         A voucher which has already been spent cannot be marked as double-spent.
         """
@@ -462,7 +497,7 @@ 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, unblinded)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
         try:
             result = store.mark_voucher_double_spent(voucher_value)
         except ValueError:
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index a8fff0d78b28dd5a6adcde78467d2b9bdb1178a8..6fb6a13963c7f4ca92ae36f4e0d91266da5f91ab 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -104,6 +104,8 @@ from ..model import (
 )
 from ..controller import (
     IssuerConfigurationMismatch,
+    PaymentController,
+    DummyRedeemer,
 )
 from .._storage_client import (
     IncorrectStorageServerReference,
@@ -120,8 +122,6 @@ from .strategies import (
     server_configurations,
     announcements,
     vouchers,
-    random_tokens,
-    unblinded_tokens,
     storage_indexes,
     lease_renew_secrets,
     lease_cancel_secrets,
@@ -386,8 +386,6 @@ class ClientPluginTests(TestCase):
         datetimes(),
         announcements(),
         vouchers(),
-        random_tokens(),
-        unblinded_tokens(),
         storage_indexes(),
         lease_renew_secrets(),
         lease_cancel_secrets(),
@@ -400,8 +398,6 @@ class ClientPluginTests(TestCase):
             now,
             announcement,
             voucher,
-            token,
-            unblinded_token,
             storage_index,
             renew_secret,
             cancel_secret,
@@ -419,8 +415,17 @@ class ClientPluginTests(TestCase):
         )
 
         store = VoucherStore.from_node_config(node_config, lambda: now)
-        store.add(voucher, lambda: [token])
-        store.insert_unblinded_tokens_for_voucher(voucher, [unblinded_token])
+        controller = PaymentController(
+            store,
+            DummyRedeemer(),
+            1,
+        )
+        # Get a token inserted into the store.
+        redeeming = controller.redeem(voucher)
+        self.assertThat(
+            redeeming,
+            succeeded(Always()),
+        )
 
         storage_client = storage_server.get_storage_client(
             node_config,
diff --git a/src/_zkapauthorizer/tests/test_schema.py b/src/_zkapauthorizer/tests/test_schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..da1af22218d7431852e341f6e7a7d1106d65f6d1
--- /dev/null
+++ b/src/_zkapauthorizer/tests/test_schema.py
@@ -0,0 +1,43 @@
+# coding: utf-8
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_zkapauthorizer.schema``.
+"""
+
+from __future__ import (
+    absolute_import,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Equals,
+)
+
+from ..schema import (
+    _UPGRADES,
+)
+
+class UpgradeTests(TestCase):
+    def test_consistency(self):
+        """
+        Upgrades are defined for every version up to the latest version.
+        """
+        self.assertThat(
+            list(_UPGRADES.keys()),
+            Equals(list(range(len(_UPGRADES)))),
+        )