diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index ab0a19a6fc4046970a1b09750d9ef361d0c44bf4..ba9089e9244280932f4c5e31b5608671fa7a99a8 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -325,6 +325,43 @@ class VoucherStore(object):
             (len(unblinded_tokens), voucher),
         )
 
+    @with_cursor
+    def mark_voucher_double_spent(self, cursor, voucher):
+        """
+        Mark a voucher as having failed redemption because it has already been
+        spent.
+        """
+        cursor.execute(
+            """
+            UPDATE [vouchers]
+            SET [state] = "double-spend"
+            WHERE [number] = ?
+              AND [state] = "pending"
+            """,
+            (voucher,)
+        )
+        if cursor.rowcount == 0:
+            # Was there no matching voucher or was it in the wrong state?
+            cursor.execute(
+                """
+                SELECT [state]
+                FROM [vouchers]
+                WHERE [number] = ?
+                """,
+                (voucher,)
+            )
+            rows = cursor.fetchall()
+            if len(rows) == 0:
+                raise ValueError("Voucher {} not found".format(voucher))
+            else:
+                raise ValueError(
+                    "Voucher {} in state {} cannot transition to double-spend".format(
+                        voucher,
+                        rows[0][0],
+                    ),
+                )
+
+
     @with_cursor
     def extract_unblinded_tokens(self, cursor, count):
         """
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 615d202bc0f76bba753dfb3d6e9d7d5b7b9f38b2..2abed45fe8b4fbee625a261d45bba8210a2dea82 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -104,13 +104,7 @@ class VoucherStoreTests(TestCase):
         ``VoucherStore.get`` raises ``KeyError`` when called with a
         voucher not previously added to the store.
         """
-        tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"node"), b"tub.port")
-        store = VoucherStore.from_node_config(
-            config,
-            lambda: now,
-            memory_connect,
-        )
+        store = store_for_test(self, get_config, lambda: now)
         self.assertThat(
             lambda: store.get(voucher),
             raises(KeyError),
@@ -122,13 +116,7 @@ class VoucherStoreTests(TestCase):
         ``VoucherStore.get`` returns a ``Voucher`` representing a voucher
         previously added to the store with ``VoucherStore.add``.
         """
-        tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"node"), b"tub.port")
-        store = VoucherStore.from_node_config(
-            config,
-            lambda: now,
-            memory_connect,
-        )
+        store = store_for_test(self, get_config, lambda: now)
         store.add(voucher, tokens)
         self.assertThat(
             store.get(voucher),
@@ -145,13 +133,7 @@ class VoucherStoreTests(TestCase):
         More than one call to ``VoucherStore.add`` with the same argument results
         in the same state as a single call.
         """
-        tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"node"), b"tub.port")
-        store = VoucherStore.from_node_config(
-            config,
-            lambda: now,
-            memory_connect,
-        )
+        store = store_for_test(self, get_config, lambda: now)
         store.add(voucher, tokens)
         store.add(voucher, [])
         self.assertThat(
@@ -171,15 +153,7 @@ class VoucherStoreTests(TestCase):
         ``VoucherStore.list`` returns a ``list`` containing a ``Voucher`` object
         for each voucher previously added.
         """
-        tempdir = self.useFixture(TempDir())
-        nodedir = tempdir.join(b"node")
-        config = get_config(nodedir, b"tub.port")
-        store = VoucherStore.from_node_config(
-            config,
-            lambda: now,
-            memory_connect,
-        )
-
+        store = store_for_test(self, get_config, lambda: now)
         for voucher in vouchers:
             store.add(voucher, [])
 
@@ -238,6 +212,7 @@ class VoucherStoreTests(TestCase):
         If the underlying database file cannot be opened then
         ``VoucherStore.from_node_config`` raises ``StoreOpenError``.
         """
+
         tempdir = self.useFixture(TempDir())
         nodedir = tempdir.join(b"node")
 
@@ -282,13 +257,7 @@ class UnblindedTokenStoreTests(TestCase):
         """
         Unblinded tokens that are added to the store can later be retrieved.
         """
-        tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"node"), b"tub.port")
-        store = VoucherStore.from_node_config(
-            config,
-            lambda: now,
-            memory_connect,
-        )
+        store = store_for_test(self, get_config, lambda: now)
         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)))
@@ -326,13 +295,7 @@ class UnblindedTokenStoreTests(TestCase):
             ),
         )
 
-        tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"node"), b"tub.port")
-        store = VoucherStore.from_node_config(
-            config,
-            lambda: now,
-            memory_connect,
-        )
+        store = store_for_test(self, get_config, lambda: now)
         store.add(voucher_value, random)
         store.insert_unblinded_tokens_for_voucher(voucher_value, unblinded)
         loaded_voucher = store.get(voucher_value)
@@ -343,3 +306,105 @@ class UnblindedTokenStoreTests(TestCase):
                 token_count=Equals(num_tokens),
             ),
         )
+
+    @given(
+        tahoe_configs(),
+        datetimes(),
+        vouchers(),
+        lists(random_tokens(), unique=True),
+    )
+    def test_mark_vouchers_double_spent(self, get_config, now, voucher_value, random_tokens):
+        """
+        A voucher which is reported as double-spent is marked in the database as
+        such.
+        """
+        store = store_for_test(self, get_config, lambda: now)
+        store.add(voucher_value, random_tokens)
+        store.mark_voucher_double_spent(voucher_value)
+        voucher = store.get(voucher_value)
+        self.assertThat(
+            voucher,
+            MatchesStructure(
+                state=Equals(u"double-spend"),
+            ),
+        )
+
+    @given(
+        tahoe_configs(),
+        datetimes(),
+        vouchers(),
+        integers(min_value=1, max_value=100),
+        data(),
+    )
+    def test_mark_spent_vouchers_double_spent(self, get_config, now, voucher_value, num_tokens, data):
+        """
+        A voucher which has already been spent cannot be marked as double-spent.
+        """
+        random = data.draw(
+            lists(
+                random_tokens(),
+                min_size=num_tokens,
+                max_size=num_tokens,
+                unique=True,
+            ),
+        )
+        unblinded = data.draw(
+            lists(
+                unblinded_tokens(),
+                min_size=num_tokens,
+                max_size=num_tokens,
+                unique=True,
+            ),
+        )
+        store = store_for_test(self, get_config, lambda: now)
+        store.add(voucher_value, random)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, unblinded)
+        try:
+            result = store.mark_voucher_double_spent(voucher_value)
+        except ValueError:
+            pass
+        except Exception as e:
+            self.fail("mark_voucher_double_spent raised the wrong exception: {}".format(e))
+        else:
+            self.fail("mark_voucher_double_spent didn't raise, returned: {}".format(result))
+
+    @given(
+        tahoe_configs(),
+        datetimes(),
+        vouchers(),
+    )
+    def test_mark_invalid_vouchers_double_spent(self, get_config, now, voucher_value):
+        """
+        A voucher which is not known cannot be marked as double-spent.
+        """
+        store = store_for_test(self, get_config, lambda: now)
+        try:
+            result = store.mark_voucher_double_spent(voucher_value)
+        except ValueError:
+            pass
+        except Exception as e:
+            self.fail("mark_voucher_double_spent raised the wrong exception: {}".format(e))
+        else:
+            self.fail("mark_voucher_double_spent didn't raise, returned: {}".format(result))
+
+
+def store_for_test(testcase, get_config, get_now):
+    """
+    Create a ``VoucherStore`` in a temporary directory associated with the
+    given test case.
+
+    :param TestCase testcase: The test case for which to build the store.
+    :param get_config: A function like the one built by ``tahoe_configs``.
+    :param get_now: A no-argument callable that returns a datetime giving a
+        time to consider as "now".
+
+    :return VoucherStore: A newly created temporary store.
+    """
+    tempdir = testcase.useFixture(TempDir())
+    config = get_config(tempdir.join(b"node"), b"tub.port")
+    store = VoucherStore.from_node_config(
+        config,
+        get_now,
+        memory_connect,
+    )
+    return store