diff --git a/.circleci/config.yml b/.circleci/config.yml
index 5a7a8f081f5207b8c318ccfcf836150fe934bfe7..bc94d6fd5440cf5a5ecee94c60329a3c64058437 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -71,14 +71,7 @@ jobs:
       - run:
           name: "Get Pip"
           command: |
-            # The CircleCI macOS environment has curl and Python but does not
-            # have pip.  So, for starters, use curl and Python to get pip.
-            if [ "<< parameters.py-version >>" == "2.7" ]; then
-
-              curl https://bootstrap.pypa.io/pip/2.7/get-pip.py -o get-pip.py
-            else
-              curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
-            fi
+            curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
             python<< parameters.py-version >> get-pip.py
 
       - run:
@@ -136,6 +129,8 @@ jobs:
 
   linux-tests: &LINUX_TESTS
     parameters:
+      py-version:
+        type: "string"
       tahoe-lafs-source:
         # The name of a niv source in nix/sources.json which corresponds to
         # a Tahoe-LAFS version. This is the version that will be declared as a
@@ -209,7 +204,8 @@ jobs:
             nix-build tests.nix \
               --argstr hypothesisProfile ci \
               --arg collectCoverage true \
-              --argstr tahoe-lafs-source << parameters.tahoe-lafs-source >>
+              --argstr tahoe-lafs-source << parameters.tahoe-lafs-source >> \
+              --argstr python python<< parameters.py-version >>
 
       - run:
           name: "Push to Cachix"
@@ -241,18 +237,17 @@ workflows:
     jobs:
     - "documentation"
     - "linux-tests":
-        matrix:
-          parameters:
-            tahoe-lafs-source:
-            - "tahoe-lafs"
+        name: "Linux tests python 3.9"
+        py-version: "39"
+        tahoe-lafs-source: "tahoe-lafs"
+
+    # https://circleci.com/docs/2.0/testing-ios/#supported-xcode-versions
+    - "macos-tests":
+        name: "macOS tests python 3.8 xcode 11.7.0"
+        py-version: "3.8"
+        xcode-version: "11.7.0"
 
     - "macos-tests":
-        matrix:
-          parameters:
-            py-version:
-            - "2.7"
-
-            xcode-version:
-            # https://circleci.com/docs/2.0/testing-ios/#supported-xcode-versions
-            - "12.3.0"
-            - "11.7.0"
+        name: "macOS tests python 3.9 xcode 12.3.0"
+        py-version: "3.9"
+        xcode-version: "12.3.0"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8ad846a84580a397c4b451643c401b80e5d2e438..28c3479b5199d7326919b7c840019c279eff55d3 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,7 +9,7 @@ jobs:
     strategy:
       matrix:
         python-version:
-          - "2.7"
+          - "3.9"
 
     steps:
     # Avoid letting Windows newlines confusing milksnake.
diff --git a/default.nix b/default.nix
index 85817ddefc528b87e8d90e42d9da04dc6a4b3f4b..47247993c937d7d3899c7667e9b22a3df8fe8f81 100644
--- a/default.nix
+++ b/default.nix
@@ -1,17 +1,19 @@
 let
   sources = import nix/sources.nix;
 in
-{ pkgs ? import sources.release2105 {}
+{ pkgs ? import sources.release2111 { }
 , pypiData ? sources.pypi-deps-db
-, mach-nix ? import sources.mach-nix { inherit pkgs pypiData; }
+, python ? "python39"
+, mach-nix ? import sources.mach-nix { inherit pkgs pypiData python; }
 , tahoe-lafs-source ? "tahoe-lafs"
 , tahoe-lafs-repo ? sources.${tahoe-lafs-source}
+, ...
 }:
   let
     lib = pkgs.lib;
-    python = "python27";
     providers = {
       _default = "sdist,nixpkgs,wheel";
+
       # mach-nix doesn't provide a good way to depend on mach-nix packages,
       # so we get it as a nixpkgs dependency from an overlay. See below for
       # details.
@@ -34,6 +36,18 @@ in
       # The version of Klein we get doesn't need / can't have the patch that
       # comes from the nixpkgs derivation mach-nix picks up from 21.05.
       klein = "wheel";
+
+      # - has an undetected poetry dependency and when trying to work around
+      #   this another way, dependencies have undetected dependencies, easier
+      #   to just use the wheel.
+      collections-extended = "wheel";
+      isort = "wheel";
+
+      # The sdists for these packages have a different source/directory layout
+      # than the github archive the nixpkgs derivations expects to operate on
+      # so the sdists provider fails to build them.
+      tomli = "wheel";
+      hypothesis = "wheel";
     };
   in
     rec {
@@ -52,12 +66,21 @@ in
         # going on and discover the real version specified by `src` below.
         version = "1.17.0.post999";
         # See https://github.com/DavHau/mach-nix/issues/190
-        requirementsExtra = ''
+        requirementsExtra =
+          ''
+          # See https://github.com/DavHau/mach-nix/issues/190
           pyrsistent < 0.17
-          foolscap == 0.13.1
           configparser
           eliot
-        '';
+          foolscap >= 21.7.0
+
+          # undetected cryptography build dependency
+          # https://github.com/DavHau/mach-nix/issues/305
+          setuptools_rust
+          # undetected tomli build dependency
+          # probably same underlying cause as cryptography issue
+          flit_core
+          '';
         postPatch = ''
           cat > src/allmydata/_version.py <<EOF
           # This _version.py is generated by nix.
diff --git a/nix/sources.json b/nix/sources.json
index fdde626e48c8f4c98f24532a65da6829345b6f6e..3107940ad6eb2e03ecd62262606c802987a8cdce 100644
--- a/nix/sources.json
+++ b/nix/sources.json
@@ -29,10 +29,10 @@
         "homepage": "",
         "owner": "DavHau",
         "repo": "pypi-deps-db",
-        "rev": "96d01556b4597c022647acbf8c3b58d2a99bc963",
-        "sha256": "0s6ll2hi40gj6mp2zdg7w3dq17g381gnfkm390mqgp574lmbq6yw",
+        "rev": "856d67ab093a68425c3896ec2961cea3b95ae93f",
+        "sha256": "0a7avm4xvm454gxy4dq0fc19j3f9ik8gf3kjpsqhszfkamrn9y0p",
         "type": "tarball",
-        "url": "https://github.com/DavHau/pypi-deps-db/archive/96d01556b4597c022647acbf8c3b58d2a99bc963.tar.gz",
+        "url": "https://github.com/DavHau/pypi-deps-db/archive/856d67ab093a68425c3896ec2961cea3b95ae93f.tar.gz",
         "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
     },
     "release2105": {
@@ -41,6 +41,18 @@
         "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.3740.ce7a1190a0f/nixexprs.tar.xz",
         "url_template": "https://releases.nixos.org/nixos/21.05/nixos-21.05.3740.ce7a1190a0f/nixexprs.tar.xz"
     },
+    "release2111": {
+        "branch": "release-21.11",
+        "description": "Nix Packages collection",
+        "homepage": "",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "d887ac7aee92e8fc54dde9060d60d927afae9d69",
+        "sha256": "1bpgfv45b1yvrgpwdgc4fm4a6sav198yd41bsrvlmm3jn2wi6qx5",
+        "type": "tarball",
+        "url": "https://github.com/NixOS/nixpkgs/archive/d887ac7aee92e8fc54dde9060d60d927afae9d69.tar.gz",
+        "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
+    },
     "tahoe-lafs": {
         "branch": "master",
         "description": "The Tahoe-LAFS decentralized secure filesystem.",
diff --git a/setup.cfg b/setup.cfg
index 90384fb4aca8c44dd6c2c867cd60c194bef1d267..768163ca8a71a5f9f8efc5f7020ad57bff10b28b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -10,8 +10,8 @@ keywords = tahoe-lafs, storage, privacy, cryptography
 license = Apache 2.0
 classifiers =
     Framework :: Twisted
-    Programming Language :: Python :: 2
-    Programming Language :: Python :: 2.7
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.9
 author = PrivateStorage.io, LLC
 maintainer = PrivateStorage.io, LLC
 home-page = https://privatestorage.io/
@@ -34,14 +34,18 @@ packages =
 install_requires =
     attrs
     zope.interface
-    eliot
+    eliot >= 1.11,<2
     aniso8601
     python-challenge-bypass-ristretto
     # The pip resolver sometimes finds treq's dependencies first and these are
     # incompatible with Tahoe-LAFS'.  So duplicate them here (the ones that
     # have been observed to cause problems).
     Twisted[tls,conch] >= 19.10.0
-    tahoe-lafs >=1.17,<1.18
+
+    # Tahoe has no stable Python API but we use its Python API so there's
+    # basically no wiggle room here.  We still use a (really tiny) range
+    # because our Nix packaging provides a Tahoe-LAFS with a .postNNN version.
+    tahoe-lafs >=1.17.0,<1.17.1
     treq
     pyutil
     prometheus-client
@@ -55,3 +59,10 @@ install_requires =
 
 [options.extras_require]
 test = coverage; fixtures; testtools; hypothesis
+
+[flake8]
+# Enforce all pyflakes constraints, and also prohibit tabs for indentation.
+# Reference:
+#   https://flake8.pycqa.org/en/latest/user/error-codes.html
+#   https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes
+select = F, W191
diff --git a/shell.nix b/shell.nix
index 023fc1bdb969c66f6b0d8da9d0c3b638396b96f1..c7ed13786f4d7d394237bcf99009423c2b22c639 100644
--- a/shell.nix
+++ b/shell.nix
@@ -3,12 +3,34 @@
 { ... }@args:
 let
   tests = import ./tests.nix args;
-  inherit (tests) pkgs;
+  inherit (tests) privatestorage lint-python;
+  inherit (privatestorage) pkgs mach-nix tahoe-lafs zkapauthorizer;
+
+  python-env = mach-nix.mkPython {
+    inherit (zkapauthorizer.meta.mach-nix) python providers;
+    overridesPre = [
+      (
+        self: super: {
+          inherit tahoe-lafs;
+        }
+      )
+    ];
+    requirements =
+      ''
+      ${builtins.readFile ./requirements/test.in}
+      ${zkapauthorizer.requirements}
+      '';
+  };
 in
 pkgs.mkShell {
+  # Avoid leaving .pyc all over the source tree when manually triggering tests
+  # runs.
+  PYTHONDONTWRITEBYTECODE = "1";
+
   buildInputs = [
-    tests.python
-    tests.lint-python
-    pkgs.niv
+    # Provide the linting tools for interactive usage.
+    lint-python
+    # Supply all of the runtime and testing dependencies.
+    python-env
   ];
 }
diff --git a/src/_zkapauthorizer/_base64.py b/src/_zkapauthorizer/_base64.py
index 473cb41cdaee3d4174dffcf7cc1ade7e5b63ac10..604797ec854612beb771e3516a615c4fd9cc02e3 100644
--- a/src/_zkapauthorizer/_base64.py
+++ b/src/_zkapauthorizer/_base64.py
@@ -16,8 +16,6 @@
 This module implements base64 encoding-related functionality.
 """
 
-from __future__ import absolute_import
-
 from base64 import b64decode as _b64decode
 from binascii import Error
 from re import compile as _compile
diff --git a/src/_zkapauthorizer/_json.py b/src/_zkapauthorizer/_json.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd148c9576d49e3059208116b049e96238783d9f
--- /dev/null
+++ b/src/_zkapauthorizer/_json.py
@@ -0,0 +1,23 @@
+# Copyright 2022 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 json import dumps as _dumps
+from typing import Any
+
+
+def dumps_utf8(o: Any) -> bytes:
+    """
+    Serialize an object to a UTF-8-encoded JSON byte string.
+    """
+    return _dumps(o).encode("utf-8")
diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py
index f6105e631f0f151ecf0677f9d26dc2ab912cb811..95a45a4388485c136e00087d29db949046e84271 100644
--- a/src/_zkapauthorizer/_plugin.py
+++ b/src/_zkapauthorizer/_plugin.py
@@ -17,21 +17,19 @@ The Twisted plugin that glues the Zero-Knowledge Access Pass system into
 Tahoe-LAFS.
 """
 
-from __future__ import absolute_import
-
 import random
 from datetime import datetime
 from functools import partial
+from typing import Callable, List
 from weakref import WeakValueDictionary
 
-try:
-    from typing import Callable
-except ImportError:
-    pass
-
 import attr
 from allmydata.client import _Client
-from allmydata.interfaces import IAnnounceableStorageServer, IFoolscapStoragePlugin
+from allmydata.interfaces import (
+    IAnnounceableStorageServer,
+    IFilesystemNode,
+    IFoolscapStoragePlugin,
+)
 from allmydata.node import MissingConfigEntry
 from challenge_bypass_ristretto import PublicKey, SigningKey
 from eliot import start_action
@@ -291,7 +289,7 @@ def _create_maintenance_service(reactor, node_config, client_node):
         get_now=get_now,
     )
     last_run_path = FilePath(
-        node_config.get_private_path(b"last-lease-maintenance-run")
+        node_config.get_private_path(u"last-lease-maintenance-run")
     )
     # Create the service to periodically run the lease maintenance operation.
     return lease_maintenance_service(
@@ -303,13 +301,16 @@ def _create_maintenance_service(reactor, node_config, client_node):
     )
 
 
-def get_root_nodes(client_node, node_config):
+def get_root_nodes(client_node, node_config) -> List[IFilesystemNode]:
+    """
+    Get the configured starting points for lease maintenance traversal.
+    """
     try:
-        rootcap = node_config.get_private_config(b"rootcap")
+        rootcap = node_config.get_private_config("rootcap")
     except MissingConfigEntry:
         return []
     else:
-        return [client_node.create_node_from_uri(rootcap)]
+        return [client_node.create_node_from_uri(rootcap.encode("utf-8"))]
 
 
 def load_signing_key(path):
diff --git a/src/_zkapauthorizer/_stack.py b/src/_zkapauthorizer/_stack.py
index c6d553371b9e064583f2cc28bcc976236b5c9a51..abb725dcc3430174c7b3d6df708f02ad23bafee5 100644
--- a/src/_zkapauthorizer/_stack.py
+++ b/src/_zkapauthorizer/_stack.py
@@ -36,7 +36,12 @@ def less_limited_stack():
     More precisely, the soft stack limit is raised to the hard limit.
     """
     soft, hard = getrlimit(RLIMIT_STACK)
-    # We can raise the soft limit to the hard limit and no higher.
-    setrlimit(RLIMIT_STACK, (hard, hard))
-    yield
-    setrlimit(RLIMIT_STACK, (soft, hard))
+    try:
+        # We can raise the soft limit to the hard limit and no higher.
+        setrlimit(RLIMIT_STACK, (hard, hard))
+    except ValueError:
+        # Well, not on macOS: https://bugs.python.org/issue34602
+        yield
+    else:
+        yield
+        setrlimit(RLIMIT_STACK, (soft, hard))
diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py
index 5f4e5685c896fcd4ed7bbc262b5c07780179529b..e8fe8c468507c60ea0fad0672798605c4f8ce62b 100644
--- a/src/_zkapauthorizer/_storage_client.py
+++ b/src/_zkapauthorizer/_storage_client.py
@@ -20,16 +20,15 @@ This is the client part of a storage access protocol.  The server part is
 implemented in ``_storage_server.py``.
 """
 
-from __future__ import absolute_import
-
 from functools import partial, wraps
+from typing import Any, Dict, List, Optional, Tuple
 
 import attr
 from allmydata.interfaces import IStorageServer
 from allmydata.util.eliotutil import log_call_deferred
 from attr.validators import provides
 from eliot.twisted import inline_callbacks
-from twisted.internet.defer import returnValue
+from twisted.internet.defer import Deferred, returnValue
 from twisted.internet.interfaces import IReactorTime
 from twisted.python.reflect import namedAny
 from zope.interface import implementer
@@ -46,6 +45,21 @@ from .storage_common import (
     slot_testv_and_readv_and_writev_message,
 )
 
+Secrets = Tuple[bytes, bytes, bytes]
+TestWriteVectors = Dict[
+    int,
+    Tuple[
+        List[
+            Tuple[int, int, bytes, bytes],
+        ],
+        List[
+            Tuple[int, bytes],
+        ],
+        Optional[int],
+    ],
+]
+ReadVector = List[Tuple[int, int]]
+
 
 class IncorrectStorageServerReference(Exception):
     """
@@ -341,7 +355,7 @@ class ZKAPAuthorizerStorageClient(object):
             num_passes,
             partial(
                 self._get_passes,
-                allocate_buckets_message(storage_index).encode("utf-8"),
+                allocate_buckets_message(storage_index),
             ),
             partial(self._spend_for_allocate_buckets, allocated_size),
         )
@@ -384,7 +398,7 @@ class ZKAPAuthorizerStorageClient(object):
                 cancel_secret,
             ),
             num_passes,
-            partial(self._get_passes, add_lease_message(storage_index).encode("utf-8")),
+            partial(self._get_passes, add_lease_message(storage_index)),
         )
         returnValue(result)
 
@@ -417,12 +431,12 @@ class ZKAPAuthorizerStorageClient(object):
     @with_rref
     def slot_testv_and_readv_and_writev(
         self,
-        rref,
-        storage_index,
-        secrets,
-        tw_vectors,
-        r_vector,
-    ):
+        rref: Any,
+        storage_index: bytes,
+        secrets: Secrets,
+        tw_vectors: TestWriteVectors,
+        r_vector: ReadVector,
+    ) -> Deferred:
         # Read operations are free.
         num_passes = 0
 
@@ -431,7 +445,7 @@ class ZKAPAuthorizerStorageClient(object):
         tw_vectors = {
             sharenum: (
                 [
-                    (offset, length, "eq", specimen)
+                    (offset, length, b"eq", specimen)
                     for (offset, length, specimen) in test_vector
                 ],
                 data_vectors,
@@ -489,7 +503,7 @@ class ZKAPAuthorizerStorageClient(object):
             num_passes,
             partial(
                 self._get_passes,
-                slot_testv_and_readv_and_writev_message(storage_index).encode("utf-8"),
+                slot_testv_and_readv_and_writev_message(storage_index),
             ),
         )
         returnValue(result)
diff --git a/src/_zkapauthorizer/_storage_server.py b/src/_zkapauthorizer/_storage_server.py
index 809b385118b2a2f27c32e623c5d97bea2b8fc3be..aa11097ae705904c287babd26905ab05e6b52bf1 100644
--- a/src/_zkapauthorizer/_storage_server.py
+++ b/src/_zkapauthorizer/_storage_server.py
@@ -21,14 +21,13 @@ This is the server part of a storage access protocol.  The client part is
 implemented in ``_storage_client.py``.
 """
 
-from __future__ import absolute_import
-
 from datetime import timedelta
 from errno import ENOENT
 from functools import partial
 from os import listdir, stat
 from os.path import join
 from struct import calcsize, unpack
+from typing import Dict, List, Optional
 
 import attr
 from allmydata.interfaces import RIStorageServer, TestAndWriteVectorsForShares
@@ -69,24 +68,6 @@ from .storage_common import (
     slot_testv_and_readv_and_writev_message,
 )
 
-try:
-    from typing import Dict, List, Optional
-except ImportError:
-    pass
-
-# The last Python 2-supporting prometheus_client nevertheless tries to use
-# FileNotFoundError, an exception type from Python 3.  Since that release,
-# prometheus_client has dropped Python 2 support entirely so there is little
-# hope of ever having this fixed upstream.  When ZKAPAuthorizer is ported to
-# Python 3, this should no longer be necessary.
-def _prometheus_client_fix():
-    import prometheus_client.exposition
-
-    prometheus_client.exposition.FileNotFoundError = IOError
-
-
-_prometheus_client_fix()
-
 # See allmydata/storage/mutable.py
 SLOT_HEADER_SIZE = 468
 LEASE_TRAILER_SIZE = 4
@@ -122,13 +103,13 @@ class _ValidationResult(object):
         """
         Cryptographically check the validity of a single pass.
 
-        :param unicode message: The shared message for pass validation.
+        :param bytes message: The shared message for pass validation.
         :param Pass pass_: The pass to validate.
 
         :return bool: ``False`` (invalid) if the pass includes a valid
             signature, ``True`` (valid) otherwise.
         """
-        assert isinstance(message, unicode), "message %r not unicode" % (message,)
+        assert isinstance(message, bytes), "message %r not bytes" % (message,)
         assert isinstance(pass_, Pass), "pass %r not a Pass" % (pass_,)
         try:
             preimage = TokenPreimage.decode_base64(pass_.preimage)
@@ -136,7 +117,8 @@ class _ValidationResult(object):
             unblinded_token = signing_key.rederive_unblinded_token(preimage)
             verification_key = unblinded_token.derive_verification_key_sha512()
             invalid_pass = verification_key.invalid_sha512(
-                proposed_signature, message.encode("utf-8")
+                proposed_signature,
+                message,
             )
             return invalid_pass
         except Exception:
@@ -148,7 +130,7 @@ class _ValidationResult(object):
         """
         Check all of the given passes for validity.
 
-        :param unicode message: The shared message for pass validation.
+        :param bytes message: The shared message for pass validation.
         :param list[bytes] passes: The encoded passes to validate.
         :param SigningKey signing_key: The signing key to use to check the passes.
 
@@ -398,7 +380,7 @@ class ZKAPAuthorizerStorageServer(Referenceable):
 
     def remote_share_sizes(self, storage_index_or_slot, sharenums):
         with start_action(
-            action_type=u"zkapauthorizer:storage-server:remote:share-sizes",
+            action_type="zkapauthorizer:storage-server:remote:share-sizes",
             storage_index_or_slot=storage_index_or_slot,
         ):
             return dict(
@@ -443,7 +425,7 @@ class ZKAPAuthorizerStorageServer(Referenceable):
           Note that the lease is *not* renewed in this case (see #254).
         """
         with start_action(
-            action_type=u"zkapauthorizer:storage-server:remote:slot-testv-and-readv-and-writev",
+            action_type="zkapauthorizer:storage-server:remote:slot-testv-and-readv-and-writev",
             storage_index=b2a(storage_index),
             path=storage_index_to_dir(storage_index),
         ):
@@ -877,7 +859,7 @@ def get_share_path(storage_server, storage_index, sharenum):
     return (
         FilePath(storage_server.sharedir)
         .preauthChild(storage_index_to_dir(storage_index))
-        .child(u"{}".format(sharenum))
+        .child("{}".format(sharenum))
     )
 
 
diff --git a/src/_zkapauthorizer/config.py b/src/_zkapauthorizer/config.py
index b30b43fbf7e3b49091ba8bc8fd4175000ee755fa..f13e895f8b6da3055d53ed1e37d62a4da43fc4f2 100644
--- a/src/_zkapauthorizer/config.py
+++ b/src/_zkapauthorizer/config.py
@@ -17,11 +17,7 @@ Helpers for reading values from the Tahoe-LAFS configuration.
 """
 
 from datetime import timedelta
-
-try:
-    from typing import Optional
-except ImportError:
-    pass
+from typing import Optional
 
 from allmydata.node import _Config
 
diff --git a/src/_zkapauthorizer/configutil.py b/src/_zkapauthorizer/configutil.py
index 2df26fc6d8257e950f7ea29d2a51d61ad31ea16f..b9b167ec580852d2ca2676fe86784f3c5928b31b 100644
--- a/src/_zkapauthorizer/configutil.py
+++ b/src/_zkapauthorizer/configutil.py
@@ -16,8 +16,6 @@
 Basic utilities related to the Tahoe configuration file.
 """
 
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 
 def _merge_dictionaries(dictionaries):
     """
diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index ab52c5bc98a8eec0d253b364945effe31f396ce0..f58192bbe09d3c9e8d86e594aa83c8741f474ba4 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -17,15 +17,13 @@ This module implements controllers (in the MVC sense) for the web interface
 for the client side of the storage plugin.
 """
 
-from __future__ import absolute_import
-
 from base64 import b64decode, b64encode
 from datetime import timedelta
 from functools import partial
 from hashlib import sha256
-from json import dumps, loads
+from json import loads
 from operator import delitem, setitem
-from sys import exc_info
+from typing import List
 
 import attr
 import challenge_bypass_ristretto
@@ -40,6 +38,7 @@ from twisted.web.client import Agent
 from zope.interface import Interface, implementer
 
 from ._base64 import urlsafe_b64decode
+from ._json import dumps_utf8
 from ._stack import less_limited_stack
 from .model import Error as model_Error
 from .model import Pass
@@ -53,14 +52,18 @@ from .model import Voucher
 RETRY_INTERVAL = timedelta(milliseconds=1000)
 
 
-@attr.s
+# It would be nice to have frozen exception types but Failure.cleanFailure
+# interacts poorly with these.
+# https://twistedmatrix.com/trac/ticket/9641
+# https://twistedmatrix.com/trac/ticket/9771
+@attr.s(auto_attribs=True)
 class UnexpectedResponse(Exception):
     """
     The issuer responded in an unexpected and unhandled way.
     """
 
-    code = attr.ib()
-    body = attr.ib()
+    code: int = attr.ib()
+    body: bytes = attr.ib()
 
 
 class AlreadySpent(Exception):
@@ -78,7 +81,7 @@ class Unpaid(Exception):
     """
 
 
-@attr.s(frozen=True)
+@attr.s(auto_attribs=True)
 class UnrecognizedFailureReason(Exception):
     """
     An attempt was made to redeem a voucher and the response contained an unknown reason.
@@ -86,7 +89,7 @@ class UnrecognizedFailureReason(Exception):
     The redemption attempt may be automatically retried at some point.
     """
 
-    response = attr.ib()
+    response: dict = attr.ib()
 
 
 @attr.s
@@ -94,15 +97,18 @@ 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 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.
+    :ivar public_key: The public key which the server proved was involved in
+        the redemption process.
     """
 
-    unblinded_tokens = attr.ib()
-    public_key = attr.ib()
+    unblinded_tokens: List[UnblindedToken] = attr.ib(
+        validator=attr.validators.instance_of(list),
+    )
+    public_key: str = attr.ib(
+        validator=attr.validators.instance_of(str),
+    )
 
 
 class IRedeemer(Interface):
@@ -238,14 +244,14 @@ class ErrorRedeemer(object):
     configured error.
     """
 
-    details = attr.ib(validator=attr.validators.instance_of(unicode))
+    details = attr.ib(validator=attr.validators.instance_of(str))
 
     @classmethod
     def make(cls, section_name, node_config, announcement, reactor):
         details = node_config.get_config(
             section=section_name,
-            option=u"details",
-        ).decode("ascii")
+            option="details",
+        )
         return cls(details)
 
     def random_tokens_for_voucher(self, voucher, counter, count):
@@ -325,7 +331,7 @@ def dummy_random_tokens(voucher, counter, count):
             # Padding is 96 (random token length) - 32 (decoded voucher
             # length) - 4 (fixed-width counter)
             b64encode(
-                v + u"{:0>4}{:0>60}".format(counter, n).encode("ascii"),
+                v + "{:0>4}{:0>60}".format(counter, n).encode("ascii"),
             ),
         )
 
@@ -340,7 +346,7 @@ class DummyRedeemer(object):
     really redeeming them, it makes up some fake ZKAPs and pretends those are
     the result.
 
-    :ivar unicode _public_key: The base64-encoded public key to return with
+    :ivar str _public_key: The base64-encoded public key to return with
         all successful redemption results.  As with the tokens returned by
         this redeemer, chances are this is not actually a valid public key.
         Its corresponding private key certainly has not been used to sign
@@ -348,7 +354,7 @@ class DummyRedeemer(object):
     """
 
     _public_key = attr.ib(
-        validator=attr.validators.instance_of(unicode),
+        validator=attr.validators.instance_of(str),
     )
 
     @classmethod
@@ -356,8 +362,8 @@ class DummyRedeemer(object):
         return cls(
             node_config.get_config(
                 section=section_name,
-                option=u"issuer-public-key",
-            ).decode(u"utf-8"),
+                option="issuer-public-key",
+            ),
         )
 
     def random_tokens_for_voucher(self, voucher, counter, count):
@@ -461,8 +467,8 @@ class RistrettoRedeemer(object):
     def make(cls, section_name, node_config, announcement, reactor):
         configured_issuer = node_config.get_config(
             section=section_name,
-            option=u"ristretto-issuer-root-url",
-        ).decode("ascii")
+            option="ristretto-issuer-root-url",
+        )
         if announcement is not None:
             # Don't let us talk to a storage server that has a different idea
             # about who issues ZKAPs.  We should lift this limitation (that is, we
@@ -473,7 +479,7 @@ class RistrettoRedeemer(object):
             # If we aren't given an announcement then we're not being used in
             # the context of a specific storage server so the check is
             # unnecessary and impossible.
-            announced_issuer = announcement[u"ristretto-issuer-root-url"]
+            announced_issuer = announcement["ristretto-issuer-root-url"]
             if announced_issuer != configured_issuer:
                 raise IssuerConfigurationMismatch(announced_issuer, configured_issuer)
 
@@ -498,13 +504,14 @@ class RistrettoRedeemer(object):
         )
         blinded_tokens = list(token.blind() for token in random_tokens)
         response = yield self._treq.post(
-            self._api_root.child(u"v1", u"redeem").to_text(),
-            dumps(
+            self._api_root.child("v1", "redeem").to_text(),
+            dumps_utf8(
                 {
-                    u"redeemVoucher": voucher.number.decode("ascii"),
-                    u"redeemCounter": counter,
-                    u"redeemTokens": list(
-                        token.encode_base64() for token in blinded_tokens
+                    "redeemVoucher": voucher.number.decode("ascii"),
+                    "redeemCounter": counter,
+                    "redeemTokens": list(
+                        token.encode_base64().decode("ascii")
+                        for token in blinded_tokens
                     ),
                 }
             ),
@@ -517,26 +524,26 @@ class RistrettoRedeemer(object):
         except ValueError:
             raise UnexpectedResponse(response.code, response_body)
 
-        success = result.get(u"success", False)
+        success = result.get("success", False)
         if not success:
-            reason = result.get(u"reason", None)
-            if reason == u"double-spend":
+            reason = result.get("reason", None)
+            if reason == "double-spend":
                 raise AlreadySpent(voucher)
-            elif reason == u"unpaid":
+            elif reason == "unpaid":
                 raise Unpaid(voucher)
 
             raise UnrecognizedFailureReason(result)
 
         self._log.info(
             "Redeemed: {public_key} {proof} {count}",
-            public_key=result[u"public-key"],
-            proof=result[u"proof"],
-            count=len(result[u"signatures"]),
+            public_key=result["public-key"],
+            proof=result["proof"],
+            count=len(result["signatures"]),
         )
 
-        marshaled_signed_tokens = result[u"signatures"]
-        marshaled_proof = result[u"proof"]
-        marshaled_public_key = result[u"public-key"]
+        marshaled_signed_tokens = result["signatures"]
+        marshaled_proof = result["proof"]
+        marshaled_public_key = result["public-key"]
 
         public_key = challenge_bypass_ristretto.PublicKey.decode_base64(
             marshaled_public_key.encode("ascii"),
@@ -660,17 +667,17 @@ class PaymentController(object):
         redeeming a voucher, if no other count is given when the redemption is
         started.
 
-    :ivar set[unicode] allowed_public_keys: The base64-encoded public keys for
+    :ivar set[str] allowed_public_keys: The base64-encoded public keys for
         which to accept tokens.
 
-    :ivar dict[unicode, Redeeming] _active: A mapping from voucher identifiers
+    :ivar dict[str, Redeeming] _active: A mapping from voucher identifiers
         which currently have redemption attempts in progress to a
         ``Redeeming`` state representing the attempt.
 
-    :ivar dict[unicode, datetime] _error: A mapping from voucher identifiers
+    :ivar dict[str, datetime] _error: A mapping from voucher identifiers
         which have recently failed with an unrecognized, transient error.
 
-    :ivar dict[unicode, datetime] _unpaid: A mapping from voucher identifiers
+    :ivar dict[str, datetime] _unpaid: A mapping from voucher identifiers
         which have recently failed a redemption attempt due to an unpaid
         response from the redemption server to timestamps when the failure was
         observed.
@@ -732,7 +739,7 @@ class PaymentController(object):
         )
 
     def _retry_redemption(self):
-        for voucher in self._error.keys() + self._unpaid.keys():
+        for voucher in list(self._error.keys()) + list(self._unpaid.keys()):
             if voucher in self._active:
                 continue
             if self.get_voucher(voucher).state.should_start_redemption():
@@ -938,7 +945,7 @@ class PaymentController(object):
             )
             self._error[voucher] = model_Error(
                 finished=self.store.now(),
-                details=reason.getErrorMessage().decode("utf-8", "replace"),
+                details=reason.getErrorMessage(),
             )
         return False
 
@@ -982,22 +989,22 @@ class PaymentController(object):
 
 
 def get_redeemer(plugin_name, node_config, announcement, reactor):
-    section_name = u"storageclient.plugins.{}".format(plugin_name)
+    section_name = "storageclient.plugins.{}".format(plugin_name)
     redeemer_kind = node_config.get_config(
         section=section_name,
-        option=u"redeemer",
-        default=u"ristretto",
+        option="redeemer",
+        default="ristretto",
     )
     return _REDEEMERS[redeemer_kind](section_name, node_config, announcement, reactor)
 
 
 _REDEEMERS = {
-    u"non": NonRedeemer.make,
-    u"dummy": DummyRedeemer.make,
-    u"double-spend": DoubleSpendRedeemer.make,
-    u"unpaid": UnpaidRedeemer.make,
-    u"error": ErrorRedeemer.make,
-    u"ristretto": RistrettoRedeemer.make,
+    "non": NonRedeemer.make,
+    "dummy": DummyRedeemer.make,
+    "double-spend": DoubleSpendRedeemer.make,
+    "unpaid": UnpaidRedeemer.make,
+    "error": ErrorRedeemer.make,
+    "ristretto": RistrettoRedeemer.make,
 }
 
 
@@ -1024,9 +1031,8 @@ def bracket(first, last, between):
     except GeneratorExit:
         raise
     except:
-        info = exc_info()
         yield last()
-        raise info[0], info[1], info[2]
+        raise
     else:
         yield last()
         returnValue(result)
diff --git a/src/_zkapauthorizer/eliot.py b/src/_zkapauthorizer/eliot.py
index 8f607d8a0e66e7605ff4d66cd5640d759bba3cdb..e9f4d49304300ee1be5f169f5e53ba97623b996e 100644
--- a/src/_zkapauthorizer/eliot.py
+++ b/src/_zkapauthorizer/eliot.py
@@ -16,91 +16,89 @@
 Eliot field, message, and action definitions for ZKAPAuthorizer.
 """
 
-from __future__ import absolute_import
-
 from eliot import ActionType, Field, MessageType
 
 PRIVACYPASS_MESSAGE = Field(
-    u"message",
-    unicode,
-    u"The PrivacyPass request-binding data associated with a pass.",
+    "message",
+    str,
+    "The PrivacyPass request-binding data associated with a pass.",
 )
 
 INVALID_REASON = Field(
-    u"reason",
-    unicode,
-    u"The reason given by the server for rejecting a pass as invalid.",
+    "reason",
+    str,
+    "The reason given by the server for rejecting a pass as invalid.",
 )
 
 PASS_COUNT = Field(
-    u"count",
+    "count",
     int,
-    u"A number of passes.",
+    "A number of passes.",
 )
 
 GET_PASSES = MessageType(
-    u"zkapauthorizer:get-passes",
+    "zkapauthorizer:get-passes",
     [PRIVACYPASS_MESSAGE, PASS_COUNT],
-    u"An attempt to spend passes is beginning.",
+    "An attempt to spend passes is beginning.",
 )
 
 SPENT_PASSES = MessageType(
-    u"zkapauthorizer:spent-passes",
+    "zkapauthorizer:spent-passes",
     [PASS_COUNT],
-    u"An attempt to spend passes has succeeded.",
+    "An attempt to spend passes has succeeded.",
 )
 
 INVALID_PASSES = MessageType(
-    u"zkapauthorizer:invalid-passes",
+    "zkapauthorizer:invalid-passes",
     [INVALID_REASON, PASS_COUNT],
-    u"An attempt to spend passes has found some to be invalid.",
+    "An attempt to spend passes has found some to be invalid.",
 )
 
 RESET_PASSES = MessageType(
-    u"zkapauthorizer:reset-passes",
+    "zkapauthorizer:reset-passes",
     [PASS_COUNT],
-    u"Some passes involved in a failed spending attempt have not definitely been spent and are being returned for future use.",
+    "Some passes involved in a failed spending attempt have not definitely been spent and are being returned for future use.",
 )
 
 SIGNATURE_CHECK_FAILED = MessageType(
-    u"zkapauthorizer:storage-client:signature-check-failed",
+    "zkapauthorizer:storage-client:signature-check-failed",
     [PASS_COUNT],
-    u"Some passes the client tried to use were rejected for having invalid signatures.",
+    "Some passes the client tried to use were rejected for having invalid signatures.",
 )
 
 CALL_WITH_PASSES = ActionType(
-    u"zkapauthorizer:storage-client:call-with-passes",
+    "zkapauthorizer:storage-client:call-with-passes",
     [PASS_COUNT],
     [],
-    u"A storage operation is being started which may spend some passes.",
+    "A storage operation is being started which may spend some passes.",
 )
 
 CURRENT_SIZES = Field(
-    u"current_sizes",
+    "current_sizes",
     dict,
-    u"A dictionary mapping the numbers of existing shares to their existing sizes.",
+    "A dictionary mapping the numbers of existing shares to their existing sizes.",
 )
 
 TW_VECTORS_SUMMARY = Field(
-    u"tw_vectors_summary",
+    "tw_vectors_summary",
     dict,
-    u"A dictionary mapping share numbers from tw_vectors to test and write vector summaries.",
+    "A dictionary mapping share numbers from tw_vectors to test and write vector summaries.",
 )
 
 NEW_SIZES = Field(
-    u"new_sizes",
+    "new_sizes",
     dict,
-    u"A dictionary like that of CURRENT_SIZES but for the sizes computed for the shares after applying tw_vectors.",
+    "A dictionary like that of CURRENT_SIZES but for the sizes computed for the shares after applying tw_vectors.",
 )
 
 NEW_PASSES = Field(
-    u"new_passes",
+    "new_passes",
     int,
-    u"The number of passes computed as being required for the change in size.",
+    "The number of passes computed as being required for the change in size.",
 )
 
 MUTABLE_PASSES_REQUIRED = MessageType(
-    u"zkapauthorizer:storage:mutable-passes-required",
+    "zkapauthorizer:storage:mutable-passes-required",
     [CURRENT_SIZES, TW_VECTORS_SUMMARY, NEW_SIZES, NEW_PASSES],
-    u"Some number of passes has been computed as the cost of updating a mutable.",
+    "Some number of passes has been computed as the cost of updating a mutable.",
 )
diff --git a/src/_zkapauthorizer/foolscap.py b/src/_zkapauthorizer/foolscap.py
index 20ba99fde74bfcf6f0a3f92281867887864c0d1c..5ff7ff92b6e9d0a96afcf78d255847fa37be0193 100644
--- a/src/_zkapauthorizer/foolscap.py
+++ b/src/_zkapauthorizer/foolscap.py
@@ -17,8 +17,6 @@ Definitions related to the Foolscap-based protocol used by ZKAPAuthorizer
 to communicate between storage clients and servers.
 """
 
-from __future__ import absolute_import
-
 import attr
 from allmydata.interfaces import Offset, RIStorageServer, StorageIndex
 from foolscap.api import Any, Copyable, DictOf, ListOf, RemoteCopy
@@ -90,7 +88,7 @@ def add_passes(schema):
     :return foolscap.remoteinterface.RemoteMethodSchema: A schema like
         ``schema`` but with one additional required argument.
     """
-    return add_arguments(schema, [(b"passes", _PassList)])
+    return add_arguments(schema, [("passes", _PassList)])
 
 
 def add_arguments(schema, kwargs):
diff --git a/src/_zkapauthorizer/lease_maintenance.py b/src/_zkapauthorizer/lease_maintenance.py
index 49adebf5253260cbf214a0671d284cb698c061dd..ed7d9672eee51c5d9c151c2f0d77f36b6ca0b678 100644
--- a/src/_zkapauthorizer/lease_maintenance.py
+++ b/src/_zkapauthorizer/lease_maintenance.py
@@ -20,11 +20,7 @@ refresh leases on all shares reachable from a root.
 from datetime import datetime, timedelta
 from errno import ENOENT
 from functools import partial
-
-try:
-    from typing import Any, Dict
-except ImportError:
-    pass
+from typing import Any, Dict, Iterable
 
 import attr
 from allmydata.interfaces import IDirectoryNode, IFilesystemNode
@@ -44,11 +40,6 @@ from .controller import bracket
 from .foolscap import ShareStat
 from .model import ILeaseMaintenanceObserver
 
-try:
-    from typing import Iterable
-except ImportError:
-    pass
-
 SERVICE_NAME = u"lease maintenance service"
 
 
@@ -395,7 +386,7 @@ def lease_maintenance_service(
     """
     interval_mean = lease_maint_config.crawl_interval_mean
     interval_range = lease_maint_config.crawl_interval_range
-    halfrange = interval_range / 2
+    halfrange = interval_range // 2
 
     def sample_interval_distribution():
         return timedelta(
@@ -507,7 +498,7 @@ def write_time_to_path(path, when):
 
     :param datetime when: The datetime to write.
     """
-    path.setContent(when.isoformat())
+    path.setContent(when.isoformat().encode("utf-8"))
 
 
 def read_time_from_path(path):
@@ -526,7 +517,7 @@ def read_time_from_path(path):
             return None
         raise
     else:
-        return parse_datetime(when)
+        return parse_datetime(when.decode("ascii"))
 
 
 def visit_storage_indexes_from_root(visitor, get_root_nodes):
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 7530d2b2613538fdbe26f5a3e05b6a1b8d35eacf..726be23690610c5681067e6f857f0c2a345bf30b 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -19,17 +19,18 @@ the storage plugin.
 
 from datetime import datetime
 from functools import wraps
-from json import dumps, loads
+from json import loads
 from sqlite3 import OperationalError
 from sqlite3 import connect as _connect
 
 import attr
-from aniso8601 import parse_datetime as _parse_datetime
+from aniso8601 import parse_datetime
 from twisted.logger import Logger
 from twisted.python.filepath import FilePath
 from zope.interface import Interface, implementer
 
 from ._base64 import urlsafe_b64decode
+from ._json import dumps_utf8
 from .schema import get_schema_upgrades, get_schema_version, run_schema_upgrades
 from .storage_common import (
     get_configured_pass_value,
@@ -39,18 +40,6 @@ from .storage_common import (
 from .validators import greater_than, has_length, is_base64_encoded
 
 
-def parse_datetime(s, **kw):
-    """
-    Like ``aniso8601.parse_datetime`` but accept unicode as well.
-    """
-    if isinstance(s, unicode):
-        s = s.encode("utf-8")
-    assert isinstance(s, bytes)
-    if "delimiter" in kw and isinstance(kw["delimiter"], unicode):
-        kw["delimiter"] = kw["delimiter"].encode("utf-8")
-    return _parse_datetime(s, **kw)
-
-
 class ILeaseMaintenanceObserver(Interface):
     """
     An object which is interested in receiving events related to the progress
@@ -86,7 +75,7 @@ class NotEnoughTokens(Exception):
     """
 
 
-CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3"
+CONFIG_DB_NAME = "privatestorageio-zkapauthz-v1.sqlite3"
 
 
 def open_and_initialize(path, connect=None):
@@ -107,10 +96,9 @@ def open_and_initialize(path, connect=None):
     except OSError as e:
         raise StoreOpenError(e)
 
-    dbfile = path.asBytesMode().path
     try:
         conn = connect(
-            dbfile,
+            path.path,
             isolation_level="IMMEDIATE",
         )
     except OperationalError as e:
@@ -380,7 +368,7 @@ class VoucherStore(object):
         Store some unblinded tokens, for example as part of a backup-restore
         process.
 
-        :param list[unicode] unblinded_tokens: The unblinded tokens to store.
+        :param list[str] unblinded_tokens: The unblinded tokens to store.
 
         :param int group_id: The unique identifier of the redemption group to
             which these tokens belong.
@@ -398,7 +386,7 @@ 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
+        :param str 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
@@ -411,9 +399,9 @@ class VoucherStore(object):
             inserted tokens, ``False`` otherwise.
         """
         if completed:
-            voucher_state = u"redeemed"
+            voucher_state = "redeemed"
         else:
-            voucher_state = u"pending"
+            voucher_state = "pending"
 
         if spendable:
             token_count_increase = len(unblinded_tokens)
@@ -683,7 +671,7 @@ class VoucherStore(object):
         )
         tokens = cursor.fetchall()
         return {
-            u"unblinded-tokens": list(token for (token,) in tokens),
+            "unblinded-tokens": list(token for (token,) in tokens),
         }
 
     def start_lease_maintenance(self):
@@ -720,9 +708,9 @@ class VoucherStore(object):
             return None
         [(started, count, finished)] = activity
         return LeaseMaintenanceActivity(
-            parse_datetime(started, delimiter=u" "),
+            parse_datetime(started, delimiter=" "),
             count,
-            parse_datetime(finished, delimiter=u" "),
+            parse_datetime(finished, delimiter=" "),
         )
 
 
@@ -901,7 +889,7 @@ class RandomToken(object):
 def _counter_attribute():
     return attr.ib(
         validator=attr.validators.and_(
-            attr.validators.instance_of((int, long)),
+            attr.validators.instance_of(int),
             greater_than(-1),
         ),
     )
@@ -923,8 +911,8 @@ class Pending(object):
 
     def to_json_v1(self):
         return {
-            u"name": u"pending",
-            u"counter": self.counter,
+            "name": "pending",
+            "counter": self.counter,
         }
 
 
@@ -944,9 +932,9 @@ class Redeeming(object):
 
     def to_json_v1(self):
         return {
-            u"name": u"redeeming",
-            u"started": self.started.isoformat(),
-            u"counter": self.counter,
+            "name": "redeeming",
+            "started": self.started.isoformat(),
+            "counter": self.counter,
         }
 
 
@@ -962,16 +950,16 @@ class Redeemed(object):
     """
 
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
-    token_count = attr.ib(validator=attr.validators.instance_of((int, long)))
+    token_count = attr.ib(validator=attr.validators.instance_of(int))
 
     def should_start_redemption(self):
         return False
 
     def to_json_v1(self):
         return {
-            u"name": u"redeemed",
-            u"finished": self.finished.isoformat(),
-            u"token-count": self.token_count,
+            "name": "redeemed",
+            "finished": self.finished.isoformat(),
+            "token-count": self.token_count,
         }
 
 
@@ -984,8 +972,8 @@ class DoubleSpend(object):
 
     def to_json_v1(self):
         return {
-            u"name": u"double-spend",
-            u"finished": self.finished.isoformat(),
+            "name": "double-spend",
+            "finished": self.finished.isoformat(),
         }
 
 
@@ -1004,8 +992,8 @@ class Unpaid(object):
 
     def to_json_v1(self):
         return {
-            u"name": u"unpaid",
-            u"finished": self.finished.isoformat(),
+            "name": "unpaid",
+            "finished": self.finished.isoformat(),
         }
 
 
@@ -1018,16 +1006,16 @@ class Error(object):
     """
 
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
-    details = attr.ib(validator=attr.validators.instance_of(unicode))
+    details = attr.ib(validator=attr.validators.instance_of(str))
 
     def should_start_redemption(self):
         return True
 
     def to_json_v1(self):
         return {
-            u"name": u"error",
-            u"finished": self.finished.isoformat(),
-            u"details": self.details,
+            "name": "error",
+            "finished": self.finished.isoformat(),
+            "details": self.details,
         }
 
 
@@ -1061,7 +1049,7 @@ class Voucher(object):
     expected_tokens = attr.ib(
         validator=attr.validators.optional(
             attr.validators.and_(
-                attr.validators.instance_of((int, long)),
+                attr.validators.instance_of(int),
                 greater_than(0),
             ),
         ),
@@ -1089,15 +1077,15 @@ class Voucher(object):
     @classmethod
     def from_row(cls, row):
         def state_from_row(state, row):
-            if state == u"pending":
+            if state == "pending":
                 return Pending(counter=row[3])
-            if state == u"double-spend":
+            if state == "double-spend":
                 return DoubleSpend(
-                    parse_datetime(row[0], delimiter=u" "),
+                    parse_datetime(row[0], delimiter=" "),
                 )
-            if state == u"redeemed":
+            if state == "redeemed":
                 return Redeemed(
-                    parse_datetime(row[0], delimiter=u" "),
+                    parse_datetime(row[0], delimiter=" "),
                     row[1],
                 )
             raise ValueError("Unknown voucher state {}".format(state))
@@ -1112,59 +1100,59 @@ class Voucher(object):
             # value represents a leap second.  However, since we also use
             # Python to generate the data in the first place, it should never
             # represent a leap second... I hope.
-            created=parse_datetime(created, delimiter=u" "),
+            created=parse_datetime(created, delimiter=" "),
             state=state_from_row(state, row[4:]),
         )
 
     @classmethod
     def from_json(cls, json):
         values = loads(json)
-        version = values.pop(u"version")
+        version = values.pop("version")
         return getattr(cls, "from_json_v{}".format(version))(values)
 
     @classmethod
     def from_json_v1(cls, values):
-        state_json = values[u"state"]
-        state_name = state_json[u"name"]
-        if state_name == u"pending":
-            state = Pending(counter=state_json[u"counter"])
-        elif state_name == u"redeeming":
+        state_json = values["state"]
+        state_name = state_json["name"]
+        if state_name == "pending":
+            state = Pending(counter=state_json["counter"])
+        elif state_name == "redeeming":
             state = Redeeming(
-                started=parse_datetime(state_json[u"started"]),
-                counter=state_json[u"counter"],
+                started=parse_datetime(state_json["started"]),
+                counter=state_json["counter"],
             )
-        elif state_name == u"double-spend":
+        elif state_name == "double-spend":
             state = DoubleSpend(
-                finished=parse_datetime(state_json[u"finished"]),
+                finished=parse_datetime(state_json["finished"]),
             )
-        elif state_name == u"redeemed":
+        elif state_name == "redeemed":
             state = Redeemed(
-                finished=parse_datetime(state_json[u"finished"]),
-                token_count=state_json[u"token-count"],
+                finished=parse_datetime(state_json["finished"]),
+                token_count=state_json["token-count"],
             )
-        elif state_name == u"unpaid":
+        elif state_name == "unpaid":
             state = Unpaid(
-                finished=parse_datetime(state_json[u"finished"]),
+                finished=parse_datetime(state_json["finished"]),
             )
-        elif state_name == u"error":
+        elif state_name == "error":
             state = Error(
-                finished=parse_datetime(state_json[u"finished"]),
-                details=state_json[u"details"],
+                finished=parse_datetime(state_json["finished"]),
+                details=state_json["details"],
             )
         else:
             raise ValueError("Unrecognized state {!r}".format(state_json))
 
         return cls(
-            number=values[u"number"].encode("ascii"),
-            expected_tokens=values[u"expected-tokens"],
+            number=values["number"].encode("ascii"),
+            expected_tokens=values["expected-tokens"],
             created=None
-            if values[u"created"] is None
-            else parse_datetime(values[u"created"]),
+            if values["created"] is None
+            else parse_datetime(values["created"]),
             state=state,
         )
 
     def to_json(self):
-        return dumps(self.marshal())
+        return dumps_utf8(self.marshal())
 
     def marshal(self):
         return self.to_json_v1()
@@ -1172,9 +1160,9 @@ class Voucher(object):
     def to_json_v1(self):
         state = self.state.to_json_v1()
         return {
-            u"number": self.number.decode("ascii"),
-            u"expected-tokens": self.expected_tokens,
-            u"created": None if self.created is None else self.created.isoformat(),
-            u"state": state,
-            u"version": 1,
+            "number": self.number.decode("ascii"),
+            "expected-tokens": self.expected_tokens,
+            "created": None if self.created is None else self.created.isoformat(),
+            "state": state,
+            "version": 1,
         }
diff --git a/src/_zkapauthorizer/private.py b/src/_zkapauthorizer/private.py
index 535c00203840ce36eaa3c4e0a9c13cf6d261dd02..7e2adb00c9a4c4ea1f0ff8fd9e4cb4c6eb86f6db 100644
--- a/src/_zkapauthorizer/private.py
+++ b/src/_zkapauthorizer/private.py
@@ -10,8 +10,6 @@ Support code for applying token-based HTTP authorization rules to a
 Twisted Web resource hierarchy.
 """
 
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 # https://github.com/twisted/nevow/issues/106 may affect this code but if so
 # then the hotfix Tahoe-LAFS applies should deal with it.
 #
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index f5db0531f7d9563ddcee051475e0c9cf2d6ff1ba..2a37249287e27d0f1f1dd7ab4d1abd32d8ad6a16 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -22,8 +22,8 @@ In the future it should also allow users to read statistics about token usage.
 """
 
 from itertools import islice
-from json import dumps, load, loads
-from sys import maxint
+from json import load, loads
+from sys import maxsize
 
 from twisted.logger import Logger
 from twisted.web.http import BAD_REQUEST
@@ -33,6 +33,7 @@ from zope.interface import Attribute
 
 from . import __version__ as _zkapauthorizer_version
 from ._base64 import urlsafe_b64decode
+from ._json import dumps_utf8
 from .config import get_configured_lease_duration
 from .controller import PaymentController, get_redeemer
 from .pricecalculator import PriceCalculator
@@ -65,18 +66,18 @@ def get_token_count(
     Retrieve the configured voucher value, in number of tokens, from the given
     configuration.
 
-    :param unicode plugin_name: The plugin name to use to choose a
+    :param str plugin_name: The plugin name to use to choose a
         configuration section.
 
     :param _Config node_config: See ``from_configuration``.
 
     :param int default: The value to return if none is configured.
     """
-    section_name = u"storageclient.plugins.{}".format(plugin_name)
+    section_name = "storageclient.plugins.{}".format(plugin_name)
     return int(
         node_config.get_config(
             section=section_name,
-            option=u"default-token-count",
+            option="default-token-count",
             default=NUM_TOKENS,
         )
     )
@@ -109,7 +110,7 @@ def from_configuration(
     :return IZKAPRoot: The root of the resource hierarchy presented by the
         client side of the plugin.
     """
-    plugin_name = u"privatestorageio-zkapauthz-v1"
+    plugin_name = "privatestorageio-zkapauthz-v1"
     if redeemer is None:
         redeemer = get_redeemer(
             plugin_name,
@@ -140,7 +141,7 @@ def from_configuration(
     )
 
     root = create_private_tree(
-        lambda: node_config.get_private_config(b"api_auth_token"),
+        lambda: node_config.get_private_config("api_auth_token").encode("utf-8"),
         authorizationless_resource_tree(
             store,
             controller,
@@ -219,7 +220,7 @@ class _CalculatePrice(Resource):
         Calculate the price in ZKAPs to store or continue storing files specified
         sizes.
         """
-        if wrong_content_type(request, u"application/json"):
+        if wrong_content_type(request, "application/json"):
             return NOT_DONE_YET
 
         application_json(request)
@@ -228,18 +229,18 @@ class _CalculatePrice(Resource):
             body_object = loads(payload)
         except ValueError:
             request.setResponseCode(BAD_REQUEST)
-            return dumps(
+            return dumps_utf8(
                 {
                     "error": "could not parse request body",
                 }
             )
 
         try:
-            version = body_object[u"version"]
-            sizes = body_object[u"sizes"]
+            version = body_object["version"]
+            sizes = body_object["sizes"]
         except (TypeError, KeyError):
             request.setResponseCode(BAD_REQUEST)
-            return dumps(
+            return dumps_utf8(
                 {
                     "error": "could not read `version` and `sizes` properties",
                 }
@@ -247,17 +248,17 @@ class _CalculatePrice(Resource):
 
         if version != 1:
             request.setResponseCode(BAD_REQUEST)
-            return dumps(
+            return dumps_utf8(
                 {
                     "error": "did not find required version number 1 in request",
                 }
             )
 
         if not isinstance(sizes, list) or not all(
-            isinstance(size, (int, long)) and size >= 0 for size in sizes
+            isinstance(size, int) and size >= 0 for size in sizes
         ):
             request.setResponseCode(BAD_REQUEST)
-            return dumps(
+            return dumps_utf8(
                 {
                     "error": "did not find required positive integer sizes list in request",
                 }
@@ -266,10 +267,10 @@ class _CalculatePrice(Resource):
         application_json(request)
 
         price = self._price_calculator.calculate(sizes)
-        return dumps(
+        return dumps_utf8(
             {
-                u"price": price,
-                u"period": self._lease_period,
+                "price": price,
+                "period": self._lease_period,
             }
         )
 
@@ -280,14 +281,14 @@ def wrong_content_type(request, required_type):
 
     :param request: The request object to check.
 
-    :param unicode required_type: The required content-type (eg
-        ``u"application/json"``).
+    :param str required_type: The required content-type (eg
+        ``"application/json"``).
 
     :return bool: ``True`` if the content-type is wrong and an error response
         has been generated.  ``False`` otherwise.
     """
     actual_type = request.requestHeaders.getRawHeaders(
-        u"content-type",
+        "content-type",
         [None],
     )[0]
     if actual_type != required_type:
@@ -303,7 +304,7 @@ def application_json(request):
 
     :param twisted.web.iweb.IRequest request: The request to modify.
     """
-    request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"])
+    request.responseHeaders.setRawHeaders("content-type", ["application/json"])
 
 
 class _ProjectVersion(Resource):
@@ -313,7 +314,7 @@ class _ProjectVersion(Resource):
 
     def render_GET(self, request):
         application_json(request)
-        return dumps(
+        return dumps_utf8(
             {
                 "version": _zkapauthorizer_version,
             }
@@ -339,24 +340,24 @@ class _UnblindedTokenCollection(Resource):
         """
         application_json(request)
         state = self._store.backup()
-        unblinded_tokens = state[u"unblinded-tokens"]
+        unblinded_tokens = state["unblinded-tokens"]
 
         limit = request.args.get(b"limit", [None])[0]
         if limit is not None:
-            limit = min(maxint, int(limit))
+            limit = min(maxsize, int(limit))
 
         position = request.args.get(b"position", [b""])[0].decode("utf-8")
 
-        return dumps(
+        return dumps_utf8(
             {
-                u"total": len(unblinded_tokens),
-                u"spendable": self._store.count_unblinded_tokens(),
-                u"unblinded-tokens": list(
+                "total": len(unblinded_tokens),
+                "spendable": self._store.count_unblinded_tokens(),
+                "unblinded-tokens": list(
                     islice(
                         (token for token in unblinded_tokens if token > position), limit
                     )
                 ),
-                u"lease-maintenance-spending": self._lease_maintenance_activity(),
+                "lease-maintenance-spending": self._lease_maintenance_activity(),
             }
         )
 
@@ -365,17 +366,17 @@ class _UnblindedTokenCollection(Resource):
         Store some unblinded tokens.
         """
         application_json(request)
-        unblinded_tokens = load(request.content)[u"unblinded-tokens"]
+        unblinded_tokens = load(request.content)["unblinded-tokens"]
         self._store.insert_unblinded_tokens(unblinded_tokens, group_id=0)
-        return dumps({})
+        return dumps_utf8({})
 
     def _lease_maintenance_activity(self):
         activity = self._store.get_latest_lease_maintenance_activity()
         if activity is None:
             return activity
         return {
-            u"when": activity.finished.isoformat(),
-            u"count": activity.passes_required,
+            "when": activity.finished.isoformat(),
+            "count": activity.passes_required,
         }
 
 
@@ -401,14 +402,14 @@ class _VoucherCollection(Resource):
         try:
             payload = loads(request.content.read())
         except Exception:
-            return bad_request(u"json request body required").render(request)
-        if payload.keys() != [u"voucher"]:
+            return bad_request("json request body required").render(request)
+        if payload.keys() != {"voucher"}:
             return bad_request(
-                u"request object must have exactly one key: 'voucher'"
+                "request object must have exactly one key: 'voucher'"
             ).render(request)
-        voucher = payload[u"voucher"]
+        voucher = payload["voucher"]
         if not is_syntactic_voucher(voucher):
-            return bad_request(u"submitted voucher is syntactically invalid").render(
+            return bad_request("submitted voucher is syntactically invalid").render(
                 request
             )
 
@@ -420,9 +421,9 @@ class _VoucherCollection(Resource):
 
     def render_GET(self, request):
         application_json(request)
-        return dumps(
+        return dumps_utf8(
             {
-                u"vouchers": list(
+                "vouchers": list(
                     self._controller.incorporate_transient_state(voucher).marshal()
                     for voucher in self._store.list()
                 ),
@@ -444,12 +445,12 @@ def is_syntactic_voucher(voucher):
     """
     :param voucher: A candidate object to inspect.
 
-    :return bool: ``True`` if and only if ``voucher`` is a unicode string
+    :return bool: ``True`` if and only if ``voucher`` is a text string
         containing a syntactically valid voucher.  This says **nothing** about
         the validity of the represented voucher itself.  A ``True`` result
-        only means the unicode string can be **interpreted** as a voucher.
+        only means the string can be **interpreted** as a voucher.
     """
-    if not isinstance(voucher, unicode):
+    if not isinstance(voucher, str):
         return False
     if len(voucher) != 44:
         # TODO.  44 is the length of 32 bytes base64 encoded.  This model
@@ -480,7 +481,7 @@ class VoucherView(Resource):
         return self._voucher.to_json()
 
 
-def bad_request(reason=u"Bad Request"):
+def bad_request(reason="Bad Request"):
     """
     :return IResource: A resource which can be rendered to produce a **BAD
         REQUEST** response.
diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py
index 9fe72b695f76f3c3d7bed9628499f45be4e98f8d..c25dfa9a95a0175cf98d2fcd3ba4dee1e66df730 100644
--- a/src/_zkapauthorizer/schema.py
+++ b/src/_zkapauthorizer/schema.py
@@ -11,9 +11,6 @@
 # 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.
 """
diff --git a/src/_zkapauthorizer/server/spending.py b/src/_zkapauthorizer/server/spending.py
index f93f1561bdaa70c041e04c44b0e42981a0dcc5c9..66fe819a3b768ac48842b7b649aec52b4f987a62 100644
--- a/src/_zkapauthorizer/server/spending.py
+++ b/src/_zkapauthorizer/server/spending.py
@@ -1,9 +1,4 @@
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-try:
-    from typing import Any
-except ImportError:
-    pass
+from typing import Any
 
 import attr
 from challenge_bypass_ristretto import PublicKey
diff --git a/src/_zkapauthorizer/spending.py b/src/_zkapauthorizer/spending.py
index 19b96ecb1f597e0ddbe26e1840853c6af7b0ef64..f718ac0056d609af45a9f0c5488ca6abb93e3571 100644
--- a/src/_zkapauthorizer/spending.py
+++ b/src/_zkapauthorizer/spending.py
@@ -16,10 +16,15 @@
 A module for logic controlling the manner in which ZKAPs are spent.
 """
 
+from __future__ import annotations
+
+from typing import Callable, List, Tuple
+
 import attr
 from zope.interface import Attribute, Interface, implementer
 
 from .eliot import GET_PASSES, INVALID_PASSES, RESET_PASSES, SPENT_PASSES
+from .model import Pass, UnblindedToken
 
 
 class IPassGroup(Interface):
@@ -101,7 +106,7 @@ class PassGroup(object):
     """
     Track the state of a group of passes intended as payment for an operation.
 
-    :ivar unicode _message: The request binding message for this group of
+    :ivar _message: The request binding message for this group of
         passes.
 
     :ivar IPassFactory _factory: The factory which created this pass group.
@@ -109,19 +114,21 @@ class PassGroup(object):
     :ivar list[Pass] passes: The passes of which this group consists.
     """
 
-    _message = attr.ib()
-    _factory = attr.ib()
-    _tokens = attr.ib()
+    _message: bytes = attr.ib(validator=attr.validators.instance_of(bytes))
+    _factory: IPassFactory = attr.ib(validator=attr.validators.provides(IPassFactory))
+    _tokens: List[Tuple[UnblindedToken, Pass]] = attr.ib(
+        validator=attr.validators.instance_of(list)
+    )
 
     @property
-    def passes(self):
+    def passes(self) -> List[Pass]:
         return list(pass_ for (unblinded_token, pass_) in self._tokens)
 
     @property
-    def unblinded_tokens(self):
+    def unblinded_tokens(self) -> List[UnblindedToken]:
         return list(unblinded_token for (unblinded_token, pass_) in self._tokens)
 
-    def split(self, select_indices):
+    def split(self, select_indices: List[int]) -> (PassGroup, PassGroup):
         selected = []
         unselected = []
         for idx, t in enumerate(self._tokens):
@@ -134,19 +141,19 @@ class PassGroup(object):
             attr.evolve(self, tokens=unselected),
         )
 
-    def expand(self, by_amount):
+    def expand(self, by_amount: int) -> PassGroup:
         return attr.evolve(
             self,
             tokens=self._tokens + self._factory.get(self._message, by_amount)._tokens,
         )
 
-    def mark_spent(self):
+    def mark_spent(self) -> None:
         self._factory._mark_spent(self.unblinded_tokens)
 
-    def mark_invalid(self, reason):
+    def mark_invalid(self, reason) -> None:
         self._factory._mark_invalid(reason, self.unblinded_tokens)
 
-    def reset(self):
+    def reset(self) -> None:
         self._factory._reset(self.unblinded_tokens)
 
 
@@ -158,12 +165,12 @@ class SpendingController(object):
     attempts when necessary.
     """
 
-    get_unblinded_tokens = attr.ib()
-    discard_unblinded_tokens = attr.ib()
-    invalidate_unblinded_tokens = attr.ib()
-    reset_unblinded_tokens = attr.ib()
+    get_unblinded_tokens: Callable[[int], List[UnblindedToken]] = attr.ib()
+    discard_unblinded_tokens: Callable[[List[UnblindedToken]], None] = attr.ib()
+    invalidate_unblinded_tokens: Callable[[List[UnblindedToken]], None] = attr.ib()
+    reset_unblinded_tokens: Callable[[List[UnblindedToken]], None] = attr.ib()
 
-    tokens_to_passes = attr.ib()
+    tokens_to_passes: Callable[[bytes, List[UnblindedToken]], List[Pass]] = attr.ib()
 
     @classmethod
     def for_store(cls, tokens_to_passes, store):
@@ -179,10 +186,10 @@ class SpendingController(object):
         unblinded_tokens = self.get_unblinded_tokens(num_passes)
         passes = self.tokens_to_passes(message, unblinded_tokens)
         GET_PASSES.log(
-            message=message,
+            message=message.decode("utf-8"),
             count=num_passes,
         )
-        return PassGroup(message, self, zip(unblinded_tokens, passes))
+        return PassGroup(message, self, list(zip(unblinded_tokens, passes)))
 
     def _mark_spent(self, unblinded_tokens):
         SPENT_PASSES.log(
diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py
index bbe326a292b502024eef0b77b4181977c4f2509d..db04c1f650eae7b35a95a5eec6c6896103d2e0d3 100644
--- a/src/_zkapauthorizer/storage_common.py
+++ b/src/_zkapauthorizer/storage_common.py
@@ -16,9 +16,8 @@
 Functionality shared between the storage client and server.
 """
 
-from __future__ import division
-
 from base64 import b64encode
+from typing import Callable
 
 import attr
 from pyutil.mathutil import div_ceil
@@ -27,7 +26,7 @@ from .eliot import MUTABLE_PASSES_REQUIRED
 from .validators import greater_than
 
 
-@attr.s(frozen=True, str=True)
+@attr.s(str=True)
 class MorePassesRequired(Exception):
     """
     Storage operations fail with ``MorePassesRequired`` when they are not
@@ -43,27 +42,27 @@ class MorePassesRequired(Exception):
         passes indicating passes which failed the signature check.
     """
 
-    valid_count = attr.ib(validator=attr.validators.instance_of((int, long)))
-    required_count = attr.ib(validator=attr.validators.instance_of((int, long)))
+    valid_count = attr.ib(validator=attr.validators.instance_of(int))
+    required_count = attr.ib(validator=attr.validators.instance_of(int))
     signature_check_failed = attr.ib(converter=frozenset)
 
 
-def _message_maker(label):
+def _message_maker(label: str) -> Callable[[str], bytes]:
     def make_message(storage_index):
-        return u"{label} {storage_index}".format(
+        return "{label} {storage_index}".format(
             label=label,
-            storage_index=b64encode(storage_index),
-        )
+            storage_index=b64encode(storage_index).decode("ascii"),
+        ).encode("ascii")
 
     return make_message
 
 
 # Functions to construct the PrivacyPass request-binding message for pass
 # construction for different Tahoe-LAFS storage operations.
-allocate_buckets_message = _message_maker(u"allocate_buckets")
-add_lease_message = _message_maker(u"add_lease")
+allocate_buckets_message = _message_maker("allocate_buckets")
+add_lease_message = _message_maker("add_lease")
 slot_testv_and_readv_and_writev_message = _message_maker(
-    u"slot_testv_and_readv_and_writev"
+    "slot_testv_and_readv_and_writev"
 )
 
 # The number of bytes we're willing to store for a lease period for each pass
@@ -80,8 +79,8 @@ def get_configured_shares_needed(node_config):
     """
     return int(
         node_config.get_config(
-            section=u"client",
-            option=u"shares.needed",
+            section="client",
+            option="shares.needed",
             default=3,
         )
     )
@@ -96,8 +95,8 @@ def get_configured_shares_total(node_config):
     """
     return int(
         node_config.get_config(
-            section=u"client",
-            option=u"shares.total",
+            section="client",
+            option="shares.total",
             default=10,
         )
     )
@@ -111,11 +110,11 @@ def get_configured_pass_value(node_config):
     value is read from the **pass-value** option of the ZKAPAuthorizer plugin
     client section.
     """
-    section_name = u"storageclient.plugins.privatestorageio-zkapauthz-v1"
+    section_name = "storageclient.plugins.privatestorageio-zkapauthz-v1"
     return int(
         node_config.get_config(
             section=section_name,
-            option=u"pass-value",
+            option="pass-value",
             default=BYTES_PER_PASS,
         )
     )
@@ -125,17 +124,20 @@ def get_configured_allowed_public_keys(node_config):
     """
     Read the set of allowed issuer public keys from the given configuration.
     """
-    section_name = u"storageclient.plugins.privatestorageio-zkapauthz-v1"
+    section_name = "storageclient.plugins.privatestorageio-zkapauthz-v1"
     return set(
         node_config.get_config(
             section=section_name,
-            option=u"allowed-public-keys",
+            option="allowed-public-keys",
         )
         .strip()
         .split(",")
     )
 
 
+_dict_values = type(dict().values())
+
+
 def required_passes(bytes_per_pass, share_sizes):
     """
     Calculate the number of passes that are required to store shares of the
@@ -148,9 +150,9 @@ def required_passes(bytes_per_pass, share_sizes):
 
     :return int: The number of passes required to cover the storage cost.
     """
-    if not isinstance(share_sizes, list):
+    if not isinstance(share_sizes, (list, _dict_values)):
         raise TypeError(
-            "Share sizes must be a list of integers, got {!r} instead".format(
+            "Share sizes must be a list (or dict_values) of integers, got {!r} instead".format(
                 share_sizes,
             ),
         )
@@ -301,7 +303,7 @@ def pass_value_attribute():
     """
     return attr.ib(
         validator=attr.validators.and_(
-            attr.validators.instance_of((int, long)),
+            attr.validators.instance_of(int),
             greater_than(0),
         ),
     )
diff --git a/src/_zkapauthorizer/tests/eliot.py b/src/_zkapauthorizer/tests/eliot.py
deleted file mode 100644
index ba010cf2b51b709f19abfa18e1e0023fa1a38418..0000000000000000000000000000000000000000
--- a/src/_zkapauthorizer/tests/eliot.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# 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.
-
-"""
-Eliot testing helpers.
-"""
-
-from __future__ import absolute_import
-
-from functools import wraps
-from unittest import SkipTest
-
-from eliot import MemoryLogger
-from eliot.testing import check_for_errors, swap_logger
-
-
-# validate_logging and capture_logging copied from Eliot around 1.11.  We
-# can't upgrade past 1.7 because we're not Python 3 compatible.
-def validate_logging(assertion, *assertionArgs, **assertionKwargs):
-    def decorator(function):
-        @wraps(function)
-        def wrapper(self, *args, **kwargs):
-            skipped = False
-
-            kwargs["logger"] = logger = MemoryLogger()
-            self.addCleanup(check_for_errors, logger)
-            # TestCase runs cleanups in reverse order, and we want this to
-            # run *before* tracebacks are checked:
-            if assertion is not None:
-                self.addCleanup(
-                    lambda: skipped
-                    or assertion(self, logger, *assertionArgs, **assertionKwargs)
-                )
-            try:
-                return function(self, *args, **kwargs)
-            except SkipTest:
-                skipped = True
-                raise
-
-        return wrapper
-
-    return decorator
-
-
-def capture_logging(assertion, *assertionArgs, **assertionKwargs):
-    """
-    Capture and validate all logging that doesn't specify a L{Logger}.
-
-    See L{validate_logging} for details on the rest of its behavior.
-    """
-
-    def decorator(function):
-        @validate_logging(assertion, *assertionArgs, **assertionKwargs)
-        @wraps(function)
-        def wrapper(self, *args, **kwargs):
-            logger = kwargs["logger"]
-            previous_logger = swap_logger(logger)
-
-            def cleanup():
-                swap_logger(previous_logger)
-
-            self.addCleanup(cleanup)
-            return function(self, *args, **kwargs)
-
-        return wrapper
-
-    return decorator
diff --git a/src/_zkapauthorizer/tests/fixtures.py b/src/_zkapauthorizer/tests/fixtures.py
index e017eadd6b42f10f6f4255caf5c110d64cfca7de..54d27b96c42014149e21d9cdcb83a6376d4df7f4 100644
--- a/src/_zkapauthorizer/tests/fixtures.py
+++ b/src/_zkapauthorizer/tests/fixtures.py
@@ -16,8 +16,6 @@
 Common fixtures to let the test suite focus on application logic.
 """
 
-from __future__ import absolute_import
-
 from base64 import b64encode
 
 import attr
@@ -49,9 +47,9 @@ class AnonymousStorageServer(Fixture):
     clock = attr.ib()
 
     def _setUp(self):
-        self.tempdir = FilePath(self.useFixture(TempDir()).join(b"storage"))
+        self.tempdir = FilePath(self.useFixture(TempDir()).join(u"storage"))
         self.storage_server = StorageServer(
-            self.tempdir.asBytesMode().path,
+            self.tempdir.path,
             b"x" * 20,
             clock=self.clock,
         )
@@ -75,7 +73,7 @@ class TemporaryVoucherStore(Fixture):
 
     def _setUp(self):
         self.tempdir = self.useFixture(TempDir())
-        self.config = self.get_config(self.tempdir.join(b"node"), b"tub.port")
+        self.config = self.get_config(self.tempdir.join(u"node"), u"tub.port")
         self.store = VoucherStore.from_node_config(
             self.config,
             self.get_now,
diff --git a/src/_zkapauthorizer/tests/foolscap.py b/src/_zkapauthorizer/tests/foolscap.py
index 3a984bea163fd4c567812556f8229508c0cb8a2d..8ad3345c45c44634052bb680032c4f8c0c18ede1 100644
--- a/src/_zkapauthorizer/tests/foolscap.py
+++ b/src/_zkapauthorizer/tests/foolscap.py
@@ -16,8 +16,6 @@
 Testing helpers related to Foolscap.
 """
 
-from __future__ import absolute_import
-
 import attr
 from allmydata.interfaces import RIStorageServer
 from foolscap.api import Any, Copyable, Referenceable, RemoteInterface
@@ -80,7 +78,7 @@ class LocalTracker(object):
         self.interfaceName = self.interface.__remote_name__
 
     def getURL(self):
-        return b"pb://abcd@127.0.0.1:12345/efgh"
+        return "pb://abcd@127.0.0.1:12345/efgh"
 
 
 @attr.s
@@ -121,7 +119,7 @@ class LocalRemote(object):
             schema = self._referenceable.getInterface()[methname]
             if self.check_args:
                 schema.checkAllArgs(args, kwargs, inbound=True)
-            _check_copyables(list(args) + kwargs.values())
+            _check_copyables(list(args) + list(kwargs.values()))
             result = self._referenceable.doRemoteCall(
                 methname,
                 args,
diff --git a/src/_zkapauthorizer/tests/json.py b/src/_zkapauthorizer/tests/json.py
index b8aa7c74548cf3f9a744dcf0cd44bc03c7afb689..729641281365d53e5d5958a14b63bf2ec25c9748 100644
--- a/src/_zkapauthorizer/tests/json.py
+++ b/src/_zkapauthorizer/tests/json.py
@@ -16,8 +16,6 @@
 A better JSON module.
 """
 
-from __future__ import absolute_import
-
 from json import loads as _loads
 
 
diff --git a/src/_zkapauthorizer/tests/privacypass.py b/src/_zkapauthorizer/tests/privacypass.py
index a2c29cc4b9daee16bfb86f1f311f1b1c4b45e4f6..fb4fcc76a35e5236fab3a18120e65aecb12f3096 100644
--- a/src/_zkapauthorizer/tests/privacypass.py
+++ b/src/_zkapauthorizer/tests/privacypass.py
@@ -16,8 +16,6 @@
 Ristretto-flavored PrivacyPass helpers for the test suite.
 """
 
-from __future__ import absolute_import
-
 from challenge_bypass_ristretto import BatchDLEQProof, PublicKey
 
 from ..model import Pass
@@ -30,7 +28,7 @@ def make_passes(signing_key, for_message, random_tokens):
     :param challenge_bypass_ristretto.SigningKey signing_key: The key to use
         to sign the passes.
 
-    :param unicode for_message: The request-binding message with which to
+    :param bytes for_message: The request-binding message with which to
         associate the passes.
 
     :param list[challenge_bypass_ristretto.RandomToken] random_tokens: The
@@ -62,7 +60,7 @@ def make_passes(signing_key, for_message, random_tokens):
         for unblinded_signature in unblinded_signatures
     )
     message_signatures = list(
-        verification_key.sign_sha512(for_message.encode("utf-8"))
+        verification_key.sign_sha512(for_message)
         for verification_key in verification_keys
     )
     passes = list(
diff --git a/src/_zkapauthorizer/tests/storage_common.py b/src/_zkapauthorizer/tests/storage_common.py
index db95a71c78308c472b7ab60f96cb09e50a4e9ac4..a168395ca073ba197ece412a39dbcbf346c2a3f6 100644
--- a/src/_zkapauthorizer/tests/storage_common.py
+++ b/src/_zkapauthorizer/tests/storage_common.py
@@ -20,9 +20,10 @@ from functools import partial
 from itertools import islice
 from os import SEEK_CUR
 from struct import pack
+from typing import Callable, Dict, List, Set
 
 import attr
-from challenge_bypass_ristretto import RandomToken
+from challenge_bypass_ristretto import RandomToken, SigningKey
 from twisted.python.filepath import FilePath
 from zope.interface import implementer
 
@@ -134,7 +135,7 @@ def whitebox_write_sparse_share(sharepath, version, size, leases, now):
         )
 
 
-def integer_passes(limit):
+def integer_passes(limit: int) -> Callable[[bytes, int], List[int]]:
     """
     :return: A function which can be used to get a number of passes.  The
         function accepts a unicode request-binding message and an integer
@@ -152,9 +153,11 @@ def integer_passes(limit):
     return get_passes
 
 
-def get_passes(message, count, signing_key):
+def get_passes(
+    message: bytes, count: int, signing_key: SigningKey
+) -> List[RandomToken]:
     """
-    :param unicode message: Request-binding message for PrivacyPass.
+    :param bytes message: Request-binding message for PrivacyPass.
 
     :param int count: The number of passes to get.
 
@@ -163,6 +166,7 @@ def get_passes(message, count, signing_key):
     :return list[Pass]: ``count`` new random passes signed with the given key
         and bound to the given message.
     """
+    assert isinstance(message, bytes)
     return make_passes(
         signing_key,
         message,
@@ -200,33 +204,32 @@ class _PassFactory(object):
     """
     A stateful pass issuer.
 
-    :ivar (unicode -> int -> [bytes]) _get_passes: A function for getting
-        passes.
+    :ivar _get_passes: A function for getting passes.
 
-    :ivar set[int] in_use: All of the passes given out without a confirmed
+    :ivar in_use: All of the passes given out without a confirmed
         terminal state.
 
-    :ivar dict[int, unicode] invalid: All of the passes given out and returned
-        using ``IPassGroup.invalid`` mapped to the reason given.
+    :ivar invalid: All of the passes given out and returned using
+        ``IPassGroup.invalid`` mapped to the reason given.
 
-    :ivar set[int] spent: All of the passes given out and returned via
+    :ivar spent: All of the passes given out and returned via
         ``IPassGroup.mark_spent``.
 
-    :ivar set[int] issued: All of the passes ever given out.
+    :ivar issued: All of the passes ever given out.
 
-    :ivar list[int] returned: A list of passes which were given out but then
-        returned via ``IPassGroup.reset``.
+    :ivar returned: A list of passes which were given out but then returned
+        via ``IPassGroup.reset``.
     """
 
-    _get_passes = attr.ib()
+    _get_passes: Callable[[bytes, int], List[bytes]] = attr.ib()
 
-    returned = attr.ib(default=attr.Factory(list), init=False)
-    in_use = attr.ib(default=attr.Factory(set), init=False)
-    invalid = attr.ib(default=attr.Factory(dict), init=False)
-    spent = attr.ib(default=attr.Factory(set), init=False)
-    issued = attr.ib(default=attr.Factory(set), init=False)
+    returned: List[int] = attr.ib(default=attr.Factory(list), init=False)
+    in_use: Set[int] = attr.ib(default=attr.Factory(set), init=False)
+    invalid: Dict[int, str] = attr.ib(default=attr.Factory(dict), init=False)
+    spent: Set[int] = attr.ib(default=attr.Factory(set), init=False)
+    issued: Set[int] = attr.ib(default=attr.Factory(set), init=False)
 
-    def get(self, message, num_passes):
+    def get(self, message: bytes, num_passes: int) -> PassGroup:
         passes = []
         if self.returned:
             passes.extend(self.returned[:num_passes])
@@ -235,7 +238,7 @@ class _PassFactory(object):
         passes.extend(self._get_passes(message, num_passes))
         self.issued.update(passes)
         self.in_use.update(passes)
-        return PassGroup(message, self, zip(passes, passes))
+        return PassGroup(message, self, list(zip(passes, passes)))
 
     def _clear(self):
         """
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 61b401967aa131463f7e83f03ba03bceda85c430..d468030d111774a4090ad3e544952b4651895f56 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -18,7 +18,7 @@ Hypothesis strategies for property testing.
 
 from base64 import b64encode, urlsafe_b64encode
 from datetime import datetime, timedelta
-from urllib import quote
+from urllib.parse import quote
 
 import attr
 from allmydata.client import config_from_string
@@ -142,7 +142,7 @@ def tahoe_config_texts(storage_client_plugins, shares):
     def merge_shares(shares, the_rest):
         for (k, v) in zip(("needed", "happy", "total"), shares):
             if v is not None:
-                the_rest["shares." + k] = u"{}".format(v)
+                the_rest["shares." + k] = f"{v}"
         return the_rest
 
     client_section = builds(
@@ -151,7 +151,7 @@ def tahoe_config_texts(storage_client_plugins, shares):
         fixed_dictionaries(
             {
                 "storage.plugins": just(
-                    u",".join(storage_client_plugins.keys()),
+                    ",".join(storage_client_plugins.keys()),
                 ),
             },
         ),
@@ -186,8 +186,8 @@ def minimal_tahoe_configs(storage_client_plugins=None, shares=just((None, None,
 
     :param shares: See ``tahoe_config_texts``.
 
-    :return SearchStrategy[unicode]: A strategy that builds unicode strings
-        which are Tahoe-LAFS configuration file contents.
+    :return SearchStrategy[str]: A strategy that builds text strings which are
+        Tahoe-LAFS configuration file contents.
     """
     if storage_client_plugins is None:
         storage_client_plugins = {}
@@ -207,9 +207,9 @@ def node_nicknames():
         alphabet=characters(
             blacklist_categories={
                 # Surrogates
-                u"Cs",
+                "Cs",
                 # Unnamed and control characters
-                u"Cc",
+                "Cc",
             },
         ),
     )
@@ -241,23 +241,23 @@ def server_configurations(signing_key_path):
     """
     Build configuration values for the server-side plugin.
 
-    :param unicode signing_key_path: A value to insert for the
+    :param str signing_key_path: A value to insert for the
         **ristretto-signing-key-path** item.
     """
     return one_of(
         fixed_dictionaries(
             {
-                u"pass-value":
+                "pass-value":
                 # The configuration is ini so everything is always a byte string!
-                integers(min_value=1).map(bytes),
+                integers(min_value=1).map(lambda v: f"{v}".encode("ascii")),
             }
         ),
         just({}),
     ).map(
         lambda config: config.update(
             {
-                u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
-                u"ristretto-signing-key-path": signing_key_path.path,
+                "ristretto-issuer-root-url": "https://issuer.example.invalid/",
+                "ristretto-signing-key-path": signing_key_path.path,
             }
         )
         or config,
@@ -294,8 +294,8 @@ def zkapauthz_configuration(
         allowed_public_keys,
     ):
         config = {
-            u"default-token-count": u"32",
-            u"allowed-public-keys": u",".join(allowed_public_keys),
+            "default-token-count": "32",
+            "allowed-public-keys": ",".join(allowed_public_keys),
         }
         config.update(extra_configuration)
         return config
@@ -314,8 +314,8 @@ def client_ristrettoredeemer_configurations():
     return zkapauthz_configuration(
         just(
             {
-                u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
-                u"redeemer": u"ristretto",
+                "ristretto-issuer-root-url": "https://issuer.example.invalid/",
+                "redeemer": "ristretto",
             }
         )
     )
@@ -351,10 +351,10 @@ def client_dummyredeemer_configurations(
         extra_config = lease_configs.map(
             lambda config: config.update(
                 {
-                    u"redeemer": u"dummy",
+                    "redeemer": "dummy",
                     # Pick out one of the allowed public keys so that the dummy
                     # appears to produce usable tokens.
-                    u"issuer-public-key": next(iter(allowed_keys)),
+                    "issuer-public-key": next(iter(allowed_keys)),
                 }
             )
             or config,
@@ -382,7 +382,7 @@ def client_doublespendredeemer_configurations(default_token_counts=token_counts(
     return zkapauthz_configuration(
         just(
             {
-                u"redeemer": u"double-spend",
+                "redeemer": "double-spend",
             }
         )
     )
@@ -395,7 +395,7 @@ def client_unpaidredeemer_configurations():
     return zkapauthz_configuration(
         just(
             {
-                u"redeemer": u"unpaid",
+                "redeemer": "unpaid",
             }
         )
     )
@@ -408,7 +408,7 @@ def client_nonredeemer_configurations():
     return zkapauthz_configuration(
         just(
             {
-                u"redeemer": u"non",
+                "redeemer": "non",
             }
         )
     )
@@ -421,8 +421,8 @@ def client_errorredeemer_configurations(details):
     return zkapauthz_configuration(
         just(
             {
-                u"redeemer": u"error",
-                u"details": details,
+                "redeemer": "error",
+                "details": details,
             }
         )
     )
@@ -492,14 +492,14 @@ def direct_tahoe_configs(
     """
     config_texts = minimal_tahoe_configs(
         {
-            u"privatestorageio-zkapauthz-v1": zkapauthz_v1_configuration,
+            "privatestorageio-zkapauthz-v1": zkapauthz_v1_configuration,
         },
         shares,
     )
     return config_texts.map(
         lambda config_text: config_from_string(
-            u"/dev/null/illegal",
-            u"",
+            "/dev/null/illegal",
+            "",
             config_text.encode("utf-8"),
         ),
     )
@@ -525,7 +525,7 @@ def tahoe_configs(
 
     def path_setter(config):
         def set_paths(basedir, portnumfile):
-            config._basedir = basedir.decode("ascii")
+            config._basedir = basedir
             config.portnum_fname = portnumfile
             return config
 
@@ -728,7 +728,11 @@ def request_paths():
 
     :see: ``requests``
     """
-    return lists(text().map(lambda x: quote(x.encode("utf-8"), safe=b"")))
+
+    def quote_segment(seg):
+        return quote(seg, safe="").encode("utf-8")
+
+    return lists(text().map(quote_segment))
 
 
 def requests(paths=request_paths()):
@@ -845,7 +849,7 @@ def bytes_for_share(sharenum, size):
         given share number
     """
     if 0 <= sharenum <= 255:
-        return (unichr(sharenum) * size).encode("latin-1")
+        return (chr(sharenum) * size).encode("latin-1")
     raise ValueError("Sharenum must be between 0 and 255 inclusive.")
 
 
@@ -949,7 +953,7 @@ def announcements():
     """
     return just(
         {
-            u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
+            "ristretto-issuer-root-url": "https://issuer.example.invalid/",
         }
     )
 
@@ -977,7 +981,7 @@ class _DirectoryNode(object):
     _storage_index = attr.ib()
     _children = attr.ib()
 
-    def list(self):
+    def list(self):  # noqa: F811
         return succeed(self._children)
 
     def get_storage_index(self):
diff --git a/src/_zkapauthorizer/tests/test_base64.py b/src/_zkapauthorizer/tests/test_base64.py
index d91bbacd065acc75295c5b0bc8b2d67d790bc2f3..df3111d280bcc6afe0454e349fe7d453cc7a5d22 100644
--- a/src/_zkapauthorizer/tests/test_base64.py
+++ b/src/_zkapauthorizer/tests/test_base64.py
@@ -16,8 +16,6 @@
 Tests for ``_zkapauthorizer._base64``.
 """
 
-from __future__ import absolute_import
-
 from base64 import urlsafe_b64encode
 
 from hypothesis import given
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 7e3e9abf1067d27a2c3a00b44bc94c1f47c9526a..c8af0774d635a99bec0801e6bfe0bf80e9807a5c 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -17,12 +17,10 @@ Tests for the web resource provided by the client part of the Tahoe-LAFS
 plugin.
 """
 
-from __future__ import absolute_import
-
 from datetime import datetime
 from io import BytesIO
-from json import dumps
-from urllib import quote
+from typing import Optional, Set
+from urllib.parse import quote
 
 import attr
 from allmydata.client import config_from_string
@@ -30,6 +28,7 @@ from aniso8601 import parse_datetime
 from fixtures import TempDir
 from hypothesis import given, note
 from hypothesis.strategies import (
+    SearchStrategy,
     binary,
     builds,
     datetimes,
@@ -73,6 +72,7 @@ from twisted.web.resource import IResource, getChildForRequest
 
 from .. import __version__ as zkapauthorizer_version
 from .._base64 import urlsafe_b64decode
+from .._json import dumps_utf8
 from ..configutil import config_string_from_sections
 from ..model import (
     DoubleSpend,
@@ -110,7 +110,7 @@ from .strategies import (
     vouchers,
 )
 
-TRANSIENT_ERROR = u"something went wrong, who knows what"
+TRANSIENT_ERROR = "something went wrong, who knows what"
 
 # Helper to work-around https://github.com/twisted/treq/issues/161
 def uncooperator(started=True):
@@ -137,17 +137,19 @@ def is_not_json(bytestring):
 
 def not_vouchers():
     """
-    Builds unicode strings which are not legal vouchers.
+    Builds byte strings which are not legal vouchers.
     """
     return one_of(
-        text().filter(
+        text()
+        .filter(
             lambda t: (not is_urlsafe_base64(t)),
-        ),
+        )
+        .map(lambda t: t.encode("utf-8")),
         vouchers().map(
             # Turn a valid voucher into a voucher that is invalid only by
             # containing a character from the base64 alphabet in place of one
             # from the urlsafe-base64 alphabet.
-            lambda voucher: u"/"
+            lambda voucher: b"/"
             + voucher[1:],
         ),
     )
@@ -155,7 +157,7 @@ def not_vouchers():
 
 def is_urlsafe_base64(text):
     """
-    :param unicode text: A candidate unicode string to inspect.
+    :param str text: A candidate text string to inspect.
 
     :return bool: ``True`` if and only if ``text`` is urlsafe-base64 encoded
     """
@@ -174,18 +176,18 @@ def invalid_bodies():
         # The wrong key but the right kind of value.
         fixed_dictionaries(
             {
-                u"some-key": vouchers(),
+                "some-key": vouchers().map(lambda v: v.decode("utf-8")),
             }
-        ).map(dumps),
+        ).map(dumps_utf8),
         # The right key but the wrong kind of value.
         fixed_dictionaries(
             {
-                u"voucher": one_of(
+                "voucher": one_of(
                     integers(),
-                    not_vouchers(),
+                    not_vouchers().map(lambda v: v.decode("utf-8")),
                 ),
             }
-        ).map(dumps),
+        ).map(dumps_utf8),
         # Not even JSON
         binary().filter(is_not_json),
     )
@@ -242,8 +244,8 @@ def authorized_request(api_auth_token, agent, method, uri, headers=None, data=No
     else:
         headers = Headers(headers)
     headers.setRawHeaders(
-        u"authorization",
-        [b"tahoe-lafs {}".format(api_auth_token)],
+        "authorization",
+        [b"tahoe-lafs " + api_auth_token],
     )
     return agent.request(
         method,
@@ -267,8 +269,8 @@ def get_config_with_api_token(tempdir, get_config, api_auth_token):
     :param bytes api_auth_token: The HTTP API authorization token to write to
         the node directory.
     """
-    basedir = tempdir.join(b"tahoe")
-    config = get_config(basedir, b"tub.port")
+    basedir = tempdir.join(u"tahoe")
+    config = get_config(basedir, u"tub.port")
     add_api_token_to_config(
         basedir,
         config,
@@ -282,9 +284,9 @@ def add_api_token_to_config(basedir, config, api_auth_token):
     Create a private directory beneath the given base directory, point the
     given config at it, and write the given API auth token to it.
     """
-    FilePath(basedir).child(b"private").makedirs()
+    FilePath(basedir).child(u"private").makedirs()
     config._basedir = basedir
-    config.write_private_config(b"api_auth_token", api_auth_token)
+    config.write_private_config(u"api_auth_token", api_auth_token)
 
 
 class FromConfigurationTests(TestCase):
@@ -299,7 +301,7 @@ class FromConfigurationTests(TestCase):
         the public keys found in the configuration.
         """
         tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        config = get_config(tempdir.join("tahoe"), "tub.port")
         allowed_public_keys = get_configured_allowed_public_keys(config)
 
         # root_from_config is just an easier way to call from_configuration
@@ -323,24 +325,24 @@ class GetTokenCountTests(TestCase):
         ``get_token_count`` returns the integer value of the
         ``default-token-count`` item from the given configuration object.
         """
-        plugin_name = u"hello-world"
+        plugin_name = "hello-world"
         if token_count is None:
             expected_count = NUM_TOKENS
             token_config = {}
         else:
             expected_count = token_count
-            token_config = {u"default-token-count": u"{}".format(expected_count)}
+            token_config = {"default-token-count": f"{expected_count}"}
 
         config_text = config_string_from_sections(
             [
                 {
-                    u"storageclient.plugins." + plugin_name: token_config,
+                    "storageclient.plugins." + plugin_name: token_config,
                 }
             ]
         )
         node_config = config_from_string(
-            self.useFixture(TempDir()).join(b"tahoe"),
-            u"tub.port",
+            self.useFixture(TempDir()).join("tahoe"),
+            "tub.port",
             config_text.encode("utf-8"),
         )
         self.assertThat(
@@ -364,7 +366,7 @@ class ResourceTests(TestCase):
         receives a 401 response.
         """
         tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        config = get_config(tempdir.join("tahoe"), "tub.port")
         root = root_from_config(config, datetime.now)
         agent = RequestTraversalAgent(root)
         requesting = agent.request(
@@ -402,7 +404,7 @@ class ResourceTests(TestCase):
         ``from_configuration``.
         """
         tempdir = self.useFixture(TempDir())
-        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        config = get_config(tempdir.join("tahoe"), "tub.port")
         root = root_from_config(config, datetime.now)
         self.assertThat(
             getChildForRequest(root, request),
@@ -490,9 +492,9 @@ class UnblindedTokenTests(TestCase):
         root = root_from_config(config, datetime.now)
         agent = RequestTraversalAgent(root)
         data = BytesIO(
-            dumps(
+            dumps_utf8(
                 {
-                    u"unblinded-tokens": list(
+                    "unblinded-tokens": list(
                         token.unblinded_token.decode("ascii")
                         for token in unblinded_tokens
                     )
@@ -514,11 +516,15 @@ class UnblindedTokenTests(TestCase):
             ),
         )
 
-        stored_tokens = root.controller.store.backup()[u"unblinded-tokens"]
+        stored_tokens = root.controller.store.backup()["unblinded-tokens"]
 
         self.assertThat(
             stored_tokens,
-            Equals(list(token.unblinded_token for token in unblinded_tokens)),
+            Equals(
+                list(
+                    token.unblinded_token.decode("ascii") for token in unblinded_tokens
+                )
+            ),
         )
 
     @given(
@@ -560,8 +566,8 @@ class UnblindedTokenTests(TestCase):
             b"http://127.0.0.1/unblinded-token",
         )
         self.addDetail(
-            u"requesting result",
-            text_content(u"{}".format(vars(requesting.result))),
+            "requesting result",
+            text_content(f"{vars(requesting.result)}"),
         )
         self.assertThat(
             requesting,
@@ -605,11 +611,11 @@ class UnblindedTokenTests(TestCase):
             api_auth_token,
             agent,
             b"GET",
-            b"http://127.0.0.1/unblinded-token?limit={}".format(limit),
+            u"http://127.0.0.1/unblinded-token?limit={}".format(limit).encode("utf-8"),
         )
         self.addDetail(
-            u"requesting result",
-            text_content(u"{}".format(vars(requesting.result))),
+            "requesting result",
+            text_content(f"{vars(requesting.result)}"),
         )
         self.assertThat(
             requesting,
@@ -658,13 +664,13 @@ class UnblindedTokenTests(TestCase):
             api_auth_token,
             agent,
             b"GET",
-            b"http://127.0.0.1/unblinded-token?position={}".format(
+            u"http://127.0.0.1/unblinded-token?position={}".format(
                 quote(position.encode("utf-8"), safe=b""),
-            ),
+            ).encode("utf-8"),
         )
         self.addDetail(
-            u"requesting result",
-            text_content(u"{}".format(vars(requesting.result))),
+            "requesting result",
+            text_content(f"{vars(requesting.result)}"),
         )
         self.assertThat(
             requesting,
@@ -674,7 +680,7 @@ class UnblindedTokenTests(TestCase):
                 AllMatch(
                     MatchesAll(
                         GreaterThan(position),
-                        IsInstance(unicode),
+                        IsInstance(str),
                     ),
                 ),
                 matches_lease_maintenance_spending(),
@@ -715,7 +721,7 @@ class UnblindedTokenTests(TestCase):
             )
             d.addCallback(readBody)
             d.addCallback(
-                lambda body: loads(body)[u"unblinded-tokens"],
+                lambda body: loads(body)["unblinded-tokens"],
             )
             return d
 
@@ -756,7 +762,7 @@ class UnblindedTokenTests(TestCase):
             succeeded(
                 MatchesPredicate(
                     check_tokens,
-                    u"initial, after (%s): initial[1:] != after",
+                    "initial, after (%s): initial[1:] != after",
                 ),
             ),
         )
@@ -803,7 +809,7 @@ class UnblindedTokenTests(TestCase):
         )
         d.addCallback(readBody)
         d.addCallback(
-            lambda body: loads(body)[u"lease-maintenance-spending"],
+            lambda body: loads(body)["lease-maintenance-spending"],
         )
         self.assertThat(
             d,
@@ -845,10 +851,10 @@ def succeeded_with_unblinded_tokens_with_matcher(
                 succeeded(
                     ContainsDict(
                         {
-                            u"total": Equals(all_token_count),
-                            u"spendable": match_spendable_token_count,
-                            u"unblinded-tokens": match_unblinded_tokens,
-                            u"lease-maintenance-spending": match_lease_maint_spending,
+                            "total": Equals(all_token_count),
+                            "spendable": match_spendable_token_count,
+                            "unblinded-tokens": match_unblinded_tokens,
+                            "lease-maintenance-spending": match_lease_maint_spending,
                         }
                     ),
                 ),
@@ -873,7 +879,7 @@ def succeeded_with_unblinded_tokens(all_token_count, returned_token_count):
         match_spendable_token_count=Equals(all_token_count),
         match_unblinded_tokens=MatchesAll(
             HasLength(returned_token_count),
-            AllMatch(IsInstance(unicode)),
+            AllMatch(IsInstance(str)),
         ),
         match_lease_maint_spending=matches_lease_maintenance_spending(),
     )
@@ -889,8 +895,8 @@ def matches_lease_maintenance_spending():
         Is(None),
         ContainsDict(
             {
-                u"when": matches_iso8601_datetime(),
-                u"amount": matches_positive_integer(),
+                "when": matches_iso8601_datetime(),
+                "amount": matches_positive_integer(),
             }
         ),
     )
@@ -905,11 +911,11 @@ def matches_positive_integer():
 
 def matches_iso8601_datetime():
     """
-    :return: A matcher which matches unicode strings which can be parsed as an
+    :return: A matcher which matches text strings which can be parsed as an
         ISO8601 datetime string.
     """
     return MatchesAll(
-        IsInstance(unicode),
+        IsInstance(str),
         AfterPreprocessing(
             parse_datetime,
             lambda d: Always(),
@@ -942,7 +948,7 @@ class VoucherTests(TestCase):
         )
         root = root_from_config(config, datetime.now)
         agent = RequestTraversalAgent(root)
-        data = BytesIO(dumps({u"voucher": voucher.decode("ascii")}))
+        data = BytesIO(dumps_utf8({"voucher": voucher.decode("ascii")}))
         requesting = authorized_request(
             api_auth_token,
             agent,
@@ -951,8 +957,8 @@ class VoucherTests(TestCase):
             data=data,
         )
         self.addDetail(
-            u"requesting result",
-            text_content(u"{}".format(vars(requesting.result))),
+            "requesting result",
+            text_content(f"{vars(requesting.result)}"),
         )
         self.assertThat(
             requesting,
@@ -983,8 +989,8 @@ class VoucherTests(TestCase):
             data=BytesIO(body),
         )
         self.addDetail(
-            u"requesting result",
-            text_content(u"{}".format(vars(requesting.result))),
+            "requesting result",
+            text_content(f"{vars(requesting.result)}"),
         )
         self.assertThat(
             requesting,
@@ -1008,9 +1014,9 @@ class VoucherTests(TestCase):
         agent = RequestTraversalAgent(root)
         url = u"http://127.0.0.1/voucher/{}".format(
             quote(
-                not_voucher.encode("utf-8"),
+                not_voucher,
                 safe=b"",
-            ).decode("utf-8"),
+            ),
         ).encode("ascii")
         requesting = authorized_request(
             api_auth_token,
@@ -1043,7 +1049,7 @@ class VoucherTests(TestCase):
             api_auth_token,
             agent,
             b"GET",
-            u"http://127.0.0.1/voucher/{}".format(voucher).encode("ascii"),
+            b"http://127.0.0.1/voucher/" + voucher,
         )
         self.assertThat(
             requesting,
@@ -1224,7 +1230,7 @@ class VoucherTests(TestCase):
             to be returned by the ``GET``.
         """
         add_api_token_to_config(
-            self.useFixture(TempDir()).join(b"tahoe"),
+            self.useFixture(TempDir()).join("tahoe"),
             config,
             api_auth_token,
         )
@@ -1235,7 +1241,7 @@ class VoucherTests(TestCase):
             agent,
             b"PUT",
             b"http://127.0.0.1/voucher",
-            data=BytesIO(dumps({u"voucher": voucher.decode("ascii")})),
+            data=BytesIO(dumps_utf8({"voucher": voucher.decode("ascii")})),
         )
         self.assertThat(
             putting,
@@ -1250,9 +1256,9 @@ class VoucherTests(TestCase):
             b"GET",
             u"http://127.0.0.1/voucher/{}".format(
                 quote(
-                    voucher.encode("utf-8"),
-                    safe=b"",
-                ).decode("utf-8"),
+                    voucher,
+                    safe=u"",
+                ),
             ).encode("ascii"),
         )
         self.assertThat(
@@ -1292,7 +1298,7 @@ class VoucherTests(TestCase):
             vouchers,
             Equals(
                 {
-                    u"vouchers": list(
+                    "vouchers": list(
                         Voucher(
                             number=voucher,
                             expected_tokens=count,
@@ -1329,7 +1335,7 @@ class VoucherTests(TestCase):
             vouchers,
             Equals(
                 {
-                    u"vouchers": list(
+                    "vouchers": list(
                         Voucher(
                             number=voucher,
                             expected_tokens=count,
@@ -1352,7 +1358,7 @@ class VoucherTests(TestCase):
             # times between setUp and tearDown.  Avoid re-using the same
             # temporary directory for every Hypothesis iteration because this
             # test leaves state behind that invalidates future iterations.
-            self.useFixture(TempDir()).join(b"tahoe"),
+            self.useFixture(TempDir()).join("tahoe"),
             config,
             api_auth_token,
         )
@@ -1362,7 +1368,7 @@ class VoucherTests(TestCase):
         note("{} vouchers".format(len(vouchers)))
 
         for voucher in vouchers:
-            data = BytesIO(dumps({u"voucher": voucher.decode("ascii")}))
+            data = BytesIO(dumps_utf8({"voucher": voucher.decode("ascii")}))
             putting = authorized_request(
                 api_auth_token,
                 agent,
@@ -1400,12 +1406,11 @@ class VoucherTests(TestCase):
         )
 
 
-def mime_types(blacklist=None):
+def mime_types(blacklist: Optional[Set[str]] = None) -> SearchStrategy[str]:
     """
     Build MIME types as b"major/minor" byte strings.
 
-    :param set|None blacklist: If not ``None``, MIME types to exclude from the
-        result.
+    :param blacklist: If not ``None``, MIME types to exclude from the result.
     """
     if blacklist is None:
         blacklist = set()
@@ -1415,7 +1420,7 @@ def mime_types(blacklist=None):
             text(),
         )
         .map(
-            b"/".join,
+            u"/".join,
         )
         .filter(
             lambda content_type: content_type not in blacklist,
@@ -1455,7 +1460,7 @@ def bad_calculate_price_requests():
     bad_headers = fixed_dictionaries(
         {
             b"content-type": mime_types(blacklist={b"application/json"},).map(
-                lambda content_type: [content_type],
+                lambda content_type: [content_type.encode("utf-8")],
             ),
         }
     )
@@ -1479,29 +1484,29 @@ def bad_calculate_price_requests():
 
     good_data = fixed_dictionaries(
         {
-            u"version": good_version,
-            u"sizes": good_sizes,
+            "version": good_version,
+            "sizes": good_sizes,
         }
-    ).map(dumps)
+    ).map(dumps_utf8)
 
     bad_data_version = fixed_dictionaries(
         {
-            u"version": bad_version,
-            u"sizes": good_sizes,
+            "version": bad_version,
+            "sizes": good_sizes,
         }
-    ).map(dumps)
+    ).map(dumps_utf8)
 
     bad_data_sizes = fixed_dictionaries(
         {
-            u"version": good_version,
-            u"sizes": bad_sizes,
+            "version": good_version,
+            "sizes": bad_sizes,
         }
-    ).map(dumps)
+    ).map(dumps_utf8)
 
     bad_data_other = dictionaries(
         text(),
         integers(),
-    ).map(dumps)
+    ).map(dumps_utf8)
 
     bad_data_junk = binary()
 
@@ -1618,7 +1623,7 @@ class CalculatePriceTests(TestCase):
         (encoding_params, min_time_remaining), config = encoding_params_and_config
         shares_needed, shares_happy, shares_total = encoding_params
         add_api_token_to_config(
-            self.useFixture(TempDir()).join(b"tahoe"),
+            self.useFixture(TempDir()).join("tahoe"),
             config,
             api_auth_token,
         )
@@ -1638,7 +1643,7 @@ class CalculatePriceTests(TestCase):
                 b"POST",
                 self.url,
                 headers={b"content-type": [b"application/json"]},
-                data=BytesIO(dumps({u"version": 1, u"sizes": sizes})),
+                data=BytesIO(dumps_utf8({"version": 1, "sizes": sizes})),
             ),
             succeeded(
                 matches_response(
@@ -1648,8 +1653,8 @@ class CalculatePriceTests(TestCase):
                         loads,
                         Equals(
                             {
-                                u"price": expected_price,
-                                u"period": 60 * 60 * 24 * 31 - min_time_remaining,
+                                "price": expected_price,
+                                "period": 60 * 60 * 24 * 31 - min_time_remaining,
                             }
                         ),
                     ),
@@ -1660,8 +1665,8 @@ class CalculatePriceTests(TestCase):
 
 def application_json():
     return AfterPreprocessing(
-        lambda h: h.getRawHeaders(u"content-type"),
-        Equals([u"application/json"]),
+        lambda h: h.getRawHeaders("content-type"),
+        Equals(["application/json"]),
     )
 
 
@@ -1672,7 +1677,7 @@ def json_content(response):
 
 
 def ok_response(headers=None):
-    return match_response(OK, headers)
+    return match_response(OK, headers, phrase=Equals(b"OK"))
 
 
 def not_found_response(headers=None):
@@ -1683,12 +1688,13 @@ def bad_request_response(headers=None):
     return match_response(BAD_REQUEST, headers)
 
 
-def match_response(code, headers):
+def match_response(code, headers, phrase=Always()):
     if headers is None:
         headers = Always()
     return _MatchResponse(
         code=Equals(code),
         headers=headers,
+        phrase=phrase,
     )
 
 
@@ -1696,18 +1702,20 @@ def match_response(code, headers):
 class _MatchResponse(object):
     code = attr.ib()
     headers = attr.ib()
+    phrase = attr.ib()
     _details = attr.ib(default=attr.Factory(dict))
 
     def match(self, response):
         self._details.update(
             {
-                u"code": response.code,
-                u"headers": response.headers.getAllRawHeaders(),
+                "code": response.code,
+                "headers": response.headers.getAllRawHeaders(),
             }
         )
         return MatchesStructure(
             code=self.code,
             headers=self.headers,
+            phrase=self.phrase,
         ).match(response)
 
     def get_details(self):
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index e4f08b1ae621d7ca86d81a6698a768106eb1a176..f48b8e11d9f7f02441839660f9afee081bd4ff86 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -16,11 +16,9 @@
 Tests for ``_zkapauthorizer.controller``.
 """
 
-from __future__ import absolute_import, division
-
 from datetime import datetime, timedelta
 from functools import partial
-from json import dumps, loads
+from json import loads
 
 import attr
 from challenge_bypass_ristretto import (
@@ -42,13 +40,14 @@ from testtools.matchers import (
     Always,
     Equals,
     HasLength,
+    Is,
     IsInstance,
     MatchesAll,
     MatchesStructure,
 )
 from testtools.twistedsupport import failed, has_no_result, succeeded
 from treq.testing import StubTreq
-from twisted.internet.defer import fail
+from twisted.internet.defer import fail, succeed
 from twisted.internet.task import Clock
 from twisted.python.url import URL
 from twisted.web.http import BAD_REQUEST, INTERNAL_SERVER_ERROR, UNSUPPORTED_MEDIA_TYPE
@@ -57,6 +56,7 @@ from twisted.web.iweb import IAgent
 from twisted.web.resource import ErrorPage, Resource
 from zope.interface import implementer
 
+from .._json import dumps_utf8
 from ..controller import (
     AlreadySpent,
     DoubleSpendRedeemer,
@@ -72,6 +72,7 @@ from ..controller import (
     Unpaid,
     UnpaidRedeemer,
     UnrecognizedFailureReason,
+    bracket,
     token_count_for_group,
 )
 from ..model import DoubleSpend as model_DoubleSpend
@@ -792,7 +793,9 @@ class RistrettoRedeemerTests(TestCase):
                         HasLength(num_tokens),
                     ),
                     public_key=Equals(
-                        PublicKey.from_signing_key(signing_key).encode_base64(),
+                        PublicKey.from_signing_key(signing_key)
+                        .encode_base64()
+                        .decode("utf-8"),
                     ),
                 ),
             ),
@@ -1151,12 +1154,12 @@ class RistrettoRedemption(Resource):
         finally:
             servers_proof.destroy()
 
-        return dumps(
+        return dumps_utf8(
             {
                 u"success": True,
-                u"public-key": self.public_key.encode_base64(),
-                u"signatures": marshaled_signed_tokens,
-                u"proof": marshaled_proof,
+                u"public-key": self.public_key.encode_base64().decode("utf-8"),
+                u"signatures": list(t.decode("utf-8") for t in marshaled_signed_tokens),
+                u"proof": marshaled_proof.decode("utf-8"),
             }
         )
 
@@ -1229,7 +1232,7 @@ class CheckRedemptionRequestTests(TestCase):
         treq = treq_for_loopback_ristretto(issuer)
         d = treq.post(
             NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"),
-            dumps(dict.fromkeys(properties)),
+            dumps_utf8(dict.fromkeys(properties)),
             headers=Headers({u"content-type": [u"application/json"]}),
         )
         self.assertThat(
@@ -1248,7 +1251,7 @@ def check_redemption_request(request):
     Verify that the given request conforms to the redemption server's public
     interface.
     """
-    if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]:
+    if request.requestHeaders.getRawHeaders(b"content-type") != [b"application/json"]:
         return bad_content_type(request)
 
     p = request.content.tell()
@@ -1279,7 +1282,7 @@ def check_redemption_request(request):
 def bad_request(request, body_object):
     request.setResponseCode(BAD_REQUEST)
     request.setHeader(b"content-type", b"application/json")
-    request.write(dumps(body_object))
+    request.write(dumps_utf8(body_object))
     return b""
 
 
@@ -1289,3 +1292,190 @@ def bad_content_type(request):
         b"Unsupported media type",
         b"Unsupported media type",
     ).render(request)
+
+
+class _BracketTestMixin:
+    """
+    Tests for ``bracket``.
+    """
+
+    def wrap_success(self, result):
+        raise NotImplementedError()
+
+    def wrap_failure(self, result):
+        raise NotImplementedError()
+
+    def test_success(self):
+        """
+        ``bracket`` calls ``first`` then ``between`` then ``last`` and returns a
+        ``Deferred`` that fires with the result of ``between``.
+        """
+        result = object()
+        actions = []
+        first = partial(actions.append, "first")
+
+        def between():
+            actions.append("between")
+            return self.wrap_success(result)
+
+        last = partial(actions.append, "last")
+        self.assertThat(
+            bracket(first, last, between),
+            succeeded(
+                Is(result),
+            ),
+        )
+        self.assertThat(
+            actions,
+            Equals(["first", "between", "last"]),
+        )
+
+    def test_failure(self):
+        """
+        ``bracket`` calls ``first`` then ``between`` then ``last`` and returns a
+        ``Deferred`` that fires with the failure result of ``between``.
+        """
+
+        class SomeException(Exception):
+            pass
+
+        actions = []
+        first = partial(actions.append, "first")
+
+        def between():
+            actions.append("between")
+            return self.wrap_failure(SomeException())
+
+        last = partial(actions.append, "last")
+        self.assertThat(
+            bracket(first, last, between),
+            failed(
+                AfterPreprocessing(
+                    lambda failure: failure.value,
+                    IsInstance(SomeException),
+                ),
+            ),
+        )
+        self.assertThat(
+            actions,
+            Equals(["first", "between", "last"]),
+        )
+
+    def test_success_with_failing_last(self):
+        """
+        If the ``between`` action succeeds and the ``last`` action fails then
+        ``bracket`` fails the same way as the ``last`` action.
+        """
+
+        class SomeException(Exception):
+            pass
+
+        actions = []
+        first = partial(actions.append, "first")
+
+        def between():
+            actions.append("between")
+            return self.wrap_success(None)
+
+        def last():
+            actions.append("last")
+            return self.wrap_failure(SomeException())
+
+        self.assertThat(
+            bracket(first, last, between),
+            failed(
+                AfterPreprocessing(
+                    lambda failure: failure.value,
+                    IsInstance(SomeException),
+                ),
+            ),
+        )
+        self.assertThat(
+            actions,
+            Equals(["first", "between", "last"]),
+        )
+
+    def test_failure_with_failing_last(self):
+        """
+        If both the ``between`` and ``last`` actions fail then ``bracket`` fails
+        the same way as the ``last`` action.
+        """
+
+        class SomeException(Exception):
+            pass
+
+        class AnotherException(Exception):
+            pass
+
+        actions = []
+        first = partial(actions.append, "first")
+
+        def between():
+            actions.append("between")
+            return self.wrap_failure(SomeException())
+
+        def last():
+            actions.append("last")
+            return self.wrap_failure(AnotherException())
+
+        self.assertThat(
+            bracket(first, last, between),
+            failed(
+                AfterPreprocessing(
+                    lambda failure: failure.value,
+                    IsInstance(AnotherException),
+                ),
+            ),
+        )
+        self.assertThat(
+            actions,
+            Equals(["first", "between", "last"]),
+        )
+
+    def test_first_failure(self):
+        """
+        If the ``first`` action fails then ``bracket`` fails the same way and
+        runs neither the ``between`` nor ``last`` actions.
+        """
+
+        class SomeException(Exception):
+            pass
+
+        actions = []
+
+        def first():
+            actions.append("first")
+            return self.wrap_failure(SomeException())
+
+        between = partial(actions.append, "between")
+        last = partial(actions.append, "last")
+
+        self.assertThat(
+            bracket(first, last, between),
+            failed(
+                AfterPreprocessing(
+                    lambda failure: failure.value,
+                    IsInstance(SomeException),
+                ),
+            ),
+        )
+        self.assertThat(
+            actions,
+            Equals(["first"]),
+        )
+
+
+class BracketTests(_BracketTestMixin, TestCase):
+    def wrap_success(self, result):
+        return result
+
+    def wrap_failure(self, exception):
+        raise exception
+
+
+class SynchronousDeferredBracketTests(_BracketTestMixin, TestCase):
+    def wrap_success(self, result):
+        return succeed(result)
+
+    def wrap_failure(self, exception):
+        return fail(exception)
diff --git a/src/_zkapauthorizer/tests/test_foolscap.py b/src/_zkapauthorizer/tests/test_foolscap.py
index 3a313b879aa720caf31b20ce97b191d9289e1424..f22985f418ba29f73c98af81fe9103271b05784d 100644
--- a/src/_zkapauthorizer/tests/test_foolscap.py
+++ b/src/_zkapauthorizer/tests/test_foolscap.py
@@ -16,8 +16,6 @@
 Tests for Foolscap-related test helpers.
 """
 
-from __future__ import absolute_import
-
 from fixtures import Fixture
 from foolscap.api import Any, RemoteInterface, Violation
 from foolscap.furl import decode_furl
@@ -55,7 +53,7 @@ class IHasSchema(RemoteInterface):
 def remote_reference():
     tub = Tub()
     tub.setLocation("127.0.0.1:12345")
-    url = tub.buildURL(b"efgh")
+    url = tub.buildURL("efgh")
 
     # Ugh ugh ugh.  Skip over the extra correctness checking in
     # RemoteReferenceTracker.__init__ that requires having a broker by passing
@@ -86,7 +84,7 @@ class LocalRemoteTests(TestCase):
         self.assertThat(
             ref.tracker.getURL(),
             MatchesAll(
-                IsInstance(bytes),
+                IsInstance(str),
                 AfterPreprocessing(
                     decode_furl,
                     Always(),
diff --git a/src/_zkapauthorizer/tests/test_lease_maintenance.py b/src/_zkapauthorizer/tests/test_lease_maintenance.py
index 02e2230c2ae2b6ad9b4d97e7c9149aeceb928722..703305b19cdd241028f42de49b6516efaf0e98e0 100644
--- a/src/_zkapauthorizer/tests/test_lease_maintenance.py
+++ b/src/_zkapauthorizer/tests/test_lease_maintenance.py
@@ -16,9 +16,8 @@
 Tests for ``_zkapauthorizer.lease_maintenance``.
 """
 
-from __future__ import absolute_import, unicode_literals
-
 from datetime import datetime, timedelta
+from typing import Dict, List
 
 import attr
 from allmydata.client import SecretHolder
@@ -78,12 +77,6 @@ from .strategies import (
     storage_indexes,
 )
 
-try:
-    from typing import Dict, List
-except ImportError:
-    pass
-
-
 default_lease_maint_config = lease_maintenance_from_tahoe_config(empty_config)
 
 
@@ -284,8 +277,8 @@ class LeaseMaintenanceServiceTests(TestCase):
         [maintenance_call] = clock.getDelayedCalls()
 
         datetime_now = datetime.utcfromtimestamp(clock.seconds())
-        low = datetime_now + mean - (range_ / 2)
-        high = datetime_now + mean + (range_ / 2)
+        low = datetime_now + mean - (range_ // 2)
+        high = datetime_now + mean + (range_ // 2)
         self.assertThat(
             datetime.utcfromtimestamp(maintenance_call.getTime()),
             between(low, high),
@@ -313,7 +306,7 @@ class LeaseMaintenanceServiceTests(TestCase):
         # Figure out the absolute last run time.
         last_run = datetime_now - since_last_run
         last_run_path = FilePath(self.useFixture(TempDir()).join("last-run"))
-        last_run_path.setContent(last_run.isoformat())
+        last_run_path.setContent(last_run.isoformat().encode("utf-8"))
 
         service = lease_maintenance_service(
             dummy_maintain_leases,
@@ -331,14 +324,14 @@ class LeaseMaintenanceServiceTests(TestCase):
 
         low = datetime_now + max(
             timedelta(0),
-            mean - (range_ / 2) - since_last_run,
+            mean - (range_ // 2) - since_last_run,
         )
         high = max(
             # If since_last_run is one microsecond (precision of timedelta)
             # then the range is indivisible.  Avoid putting the expected high
             # below the expected low.
             low,
-            datetime_now + mean + (range_ / 2) - since_last_run,
+            datetime_now + mean + (range_ // 2) - since_last_run,
         )
 
         note(
diff --git a/src/_zkapauthorizer/tests/test_matchers.py b/src/_zkapauthorizer/tests/test_matchers.py
index e34bb8ab63f8c98d0dc1b63aaab26d9f45589119..d97ac1a16de5465357629cbac314cda4d8d36e36 100644
--- a/src/_zkapauthorizer/tests/test_matchers.py
+++ b/src/_zkapauthorizer/tests/test_matchers.py
@@ -16,8 +16,6 @@
 Tests for ``_zkapauthorizer.tests.matchers``.
 """
 
-from __future__ import absolute_import
-
 from testtools import TestCase
 from testtools.matchers import Is, Not
 from zope.interface import Interface, implementer
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 93605cd83f2f373021707a0edb5cf7d49df81024..f5d195f21a005b1f42e2fc8f30ef640334577fa5 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -17,8 +17,6 @@
 Tests for ``_zkapauthorizer.model``.
 """
 
-from __future__ import absolute_import
-
 from datetime import datetime, timedelta
 from errno import EACCES
 from os import mkdir
@@ -141,8 +139,8 @@ class VoucherStoreTests(TestCase):
         """
         counter_a = counters[0]
         counter_b = counters[1]
-        tokens_a = tokens[: len(tokens) / 2]
-        tokens_b = tokens[len(tokens) / 2 :]
+        tokens_a = tokens[: len(tokens) // 2]
+        tokens_b = tokens[len(tokens) // 2 :]
 
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         # We only have to get the expected_tokens value (len(tokens)) right on
@@ -253,13 +251,13 @@ class VoucherStoreTests(TestCase):
         then ``VoucherStore.from_node_config`` raises ``StoreOpenError``.
         """
         tempdir = self.useFixture(TempDir())
-        nodedir = tempdir.join(b"node")
+        nodedir = tempdir.join(u"node")
 
         # Create the node directory without permission to create the
         # underlying directory.
         mkdir(nodedir, 0o500)
 
-        config = get_config(nodedir, b"tub.port")
+        config = get_config(nodedir, u"tub.port")
 
         self.assertThat(
             lambda: VoucherStore.from_node_config(
@@ -269,7 +267,7 @@ class VoucherStoreTests(TestCase):
             ),
             Raises(
                 AfterPreprocessing(
-                    lambda (type, exc, tb): exc,
+                    lambda exc_info: exc_info[1],
                     MatchesAll(
                         IsInstance(StoreOpenError),
                         MatchesStructure(
@@ -295,9 +293,9 @@ class VoucherStoreTests(TestCase):
         ``VoucherStore.from_node_config`` raises ``StoreOpenError``.
         """
         tempdir = self.useFixture(TempDir())
-        nodedir = tempdir.join(b"node")
+        nodedir = tempdir.join(u"node")
 
-        config = get_config(nodedir, b"tub.port")
+        config = get_config(nodedir, u"tub.port")
 
         # Create the underlying database file.
         store = VoucherStore.from_node_config(config, lambda: now)
@@ -358,9 +356,9 @@ class VoucherStoreTests(TestCase):
         :return: A three-tuple of (backed up tokens, extracted tokens, inserted tokens).
         """
         tempdir = self.useFixture(TempDir())
-        nodedir = tempdir.join(b"node")
+        nodedir = tempdir.join(u"node")
 
-        config = get_config(nodedir, b"tub.port")
+        config = get_config(nodedir, u"tub.port")
 
         # Create the underlying database file.
         store = VoucherStore.from_node_config(config, lambda: now)
@@ -384,14 +382,15 @@ class VoucherStoreTests(TestCase):
         while tokens_remaining > 0:
             to_spend = data.draw(integers(min_value=1, max_value=tokens_remaining))
             extracted_tokens.extend(
-                token.unblinded_token for token in store.get_unblinded_tokens(to_spend)
+                token.unblinded_token.decode("ascii")
+                for token in store.get_unblinded_tokens(to_spend)
             )
             tokens_remaining -= to_spend
 
         return (
             backed_up_tokens,
             extracted_tokens,
-            list(token.unblinded_token for token in unblinded_tokens),
+            list(token.unblinded_token.decode("ascii") for token in unblinded_tokens),
         )
 
 
@@ -946,7 +945,7 @@ def store_for_test(testcase, get_config, get_now):
     :return VoucherStore: A newly created temporary store.
     """
     tempdir = testcase.useFixture(TempDir())
-    config = get_config(tempdir.join(b"node"), b"tub.port")
+    config = get_config(tempdir.join(u"node"), u"tub.port")
     store = VoucherStore.from_node_config(
         config,
         get_now,
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index f300d3a6ece15d8ba48479bb437c300a2057fc0d..27e2a5a679cdb5c4260bb9101f02ad3b616e8171 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -16,21 +16,21 @@
 Tests for the Tahoe-LAFS plugin.
 """
 
-from __future__ import absolute_import
-
 from datetime import timedelta
 from functools import partial
+from io import StringIO
 from os import makedirs
 
 from allmydata.client import config_from_string, create_client_from_config
 from allmydata.interfaces import (
     IAnnounceableStorageServer,
+    IFilesystemNode,
     IFoolscapStoragePlugin,
     IStorageServer,
     RIStorageServer,
 )
 from challenge_bypass_ristretto import SigningKey
-from eliot.testing import LoggedMessage
+from eliot.testing import LoggedMessage, capture_logging
 from fixtures import TempDir
 from foolscap.broker import Broker
 from foolscap.ipb import IReferenceable, IRemotelyCallable
@@ -39,7 +39,6 @@ from hypothesis import given, settings
 from hypothesis.strategies import datetimes, just, sampled_from, timedeltas
 from prometheus_client import Gauge
 from prometheus_client.parser import text_string_to_metric_families
-from StringIO import StringIO
 from testtools import TestCase
 from testtools.content import text_content
 from testtools.matchers import (
@@ -68,7 +67,7 @@ from twisted.web.resource import IResource
 
 from twisted.plugins.zkapauthorizer import storage_server
 
-from .._plugin import load_signing_key
+from .._plugin import get_root_nodes, load_signing_key
 from .._storage_client import IncorrectStorageServerReference
 from ..controller import DummyRedeemer, IssuerConfigurationMismatch, PaymentController
 from ..foolscap import RIPrivacyPassAuthorizedStorageServer
@@ -76,7 +75,6 @@ from ..lease_maintenance import SERVICE_NAME, LeaseMaintenanceConfig
 from ..model import NotEnoughTokens, VoucherStore
 from ..spending import GET_PASSES
 from .common import skipIf
-from .eliot import capture_logging
 from .foolscap import DummyReferenceable, LocalRemote, get_anonymous_storage_server
 from .matchers import Provides, raises
 from .strategies import (
@@ -99,7 +97,7 @@ from .strategies import (
     vouchers,
 )
 
-SIGNING_KEY_PATH = FilePath(__file__).sibling(u"testing-signing.key")
+SIGNING_KEY_PATH = FilePath(__file__).sibling("testing-signing.key")
 
 
 def get_rref(interface=None):
@@ -281,12 +279,12 @@ class ServerPluginTests(TestCase):
         and an interval how often to do so, test that metrics are actually
         written there after the configured interval.
         """
-        metrics_path = self.useFixture(TempDir()).join(u"metrics")
+        metrics_path = self.useFixture(TempDir()).join("metrics")
         configuration = {
-            u"prometheus-metrics-path": metrics_path,
-            u"prometheus-metrics-interval": str(int(metrics_interval.total_seconds())),
-            u"ristretto-issuer-root-url": "foo",
-            u"ristretto-signing-key-path": SIGNING_KEY_PATH.path,
+            "prometheus-metrics-path": metrics_path,
+            "prometheus-metrics-interval": str(int(metrics_interval.total_seconds())),
+            "ristretto-issuer-root-url": "foo",
+            "ristretto-signing-key-path": SIGNING_KEY_PATH.path,
         }
         announceable = extract_result(
             storage_server.get_storage_server(
@@ -341,8 +339,8 @@ tahoe_configs_with_dummy_redeemer = tahoe_configs(client_dummyredeemer_configura
 
 tahoe_configs_with_mismatched_issuer = minimal_tahoe_configs(
     {
-        u"privatestorageio-zkapauthz-v1": just(
-            {u"ristretto-issuer-root-url": u"https://another-issuer.example.invalid/"}
+        "privatestorageio-zkapauthz-v1": just(
+            {"ristretto-issuer-root-url": "https://another-issuer.example.invalid/"}
         ),
     }
 )
@@ -362,8 +360,8 @@ class ClientPluginTests(TestCase):
         """
         tempdir = self.useFixture(TempDir())
         node_config = get_config(
-            tempdir.join(b"node"),
-            b"tub.port",
+            tempdir.join(u"node"),
+            u"tub.port",
         )
 
         storage_client = storage_server.get_storage_client(
@@ -385,24 +383,14 @@ class ClientPluginTests(TestCase):
         """
         tempdir = self.useFixture(TempDir())
         node_config = config_from_string(
-            tempdir.join(b"node"),
-            b"tub.port",
+            tempdir.join(u"node"),
+            u"tub.port",
             config_text.encode("utf-8"),
         )
-        # On Tahoe-LAFS <1.16, the config is written as bytes.
-        # On Tahoe-LAFS >=1.16, the config is written as unicode.
-        #
-        # So we'll use `StringIO.StringIO` (not `io.StringIO`) here - which
-        # will allow either type (it will also implicitly decode bytes to
-        # unicode if we mix them, though I don't think that should happen
-        # here).
-        #
-        # After support for Tahoe <1.16 support is dropped we probably want to
-        # switch to an io.StringIO here.
         config_text = StringIO()
         node_config.config.write(config_text)
-        self.addDetail(u"config", text_content(config_text.getvalue()))
-        self.addDetail(u"announcement", text_content(unicode(announcement)))
+        self.addDetail("config", text_content(config_text.getvalue()))
+        self.addDetail("announcement", text_content(str(announcement)))
         self.assertThat(
             lambda: storage_server.get_storage_client(
                 node_config,
@@ -439,8 +427,8 @@ class ClientPluginTests(TestCase):
         """
         tempdir = self.useFixture(TempDir())
         node_config = get_config(
-            tempdir.join(b"node"),
-            b"tub.port",
+            tempdir.join(u"node"),
+            u"tub.port",
         )
 
         storage_client = storage_server.get_storage_client(
@@ -489,8 +477,8 @@ class ClientPluginTests(TestCase):
         """
         tempdir = self.useFixture(TempDir())
         node_config = get_config(
-            tempdir.join(b"node"),
-            b"tub.port",
+            tempdir.join(u"node"),
+            u"tub.port",
         )
 
         store = VoucherStore.from_node_config(node_config, lambda: now)
@@ -521,12 +509,12 @@ class ClientPluginTests(TestCase):
         # tests, at least until creating a real server doesn't involve so much
         # complex setup.  So avoid using any of the client APIs that make a
         # remote call ... which is all of them.
-        pass_group = storage_client._get_passes(u"request binding message", num_passes)
+        pass_group = storage_client._get_passes(b"request binding message", num_passes)
         pass_group.mark_spent()
 
         # There should be no unblinded tokens left to extract.
         self.assertThat(
-            lambda: storage_client._get_passes(u"request binding message", 1),
+            lambda: storage_client._get_passes(b"request binding message", 1),
             raises(NotEnoughTokens),
         )
 
@@ -540,8 +528,8 @@ class ClientPluginTests(TestCase):
                         lambda logged_message: logged_message.message,
                         ContainsDict(
                             {
-                                u"message": Equals(u"request binding message"),
-                                u"count": Equals(num_passes),
+                                "message": Equals(u"request binding message"),
+                                "count": Equals(num_passes),
                             }
                         ),
                     ),
@@ -562,8 +550,8 @@ class ClientResourceTests(TestCase):
         ``get_client_resource`` returns an object that provides ``IResource``.
         """
         tempdir = self.useFixture(TempDir())
-        nodedir = tempdir.join(b"node")
-        config = get_config(nodedir, b"tub.port")
+        nodedir = tempdir.join(u"node")
+        config = get_config(nodedir, u"tub.port")
         self.assertThat(
             storage_server.get_client_resource(
                 config,
@@ -625,25 +613,63 @@ class LeaseMaintenanceServiceTests(TestCase):
             file, ``False`` otherwise.
         """
         tempdir = self.useFixture(TempDir())
-        nodedir = tempdir.join(b"node")
-        privatedir = tempdir.join(b"node", b"private")
+        nodedir = tempdir.join(u"node")
+        privatedir = tempdir.join(u"node", u"private")
         makedirs(privatedir)
-        config = get_config(nodedir, b"tub.port")
+        config = get_config(nodedir, u"tub.port")
+
+        # In Tahoe-LAFS 1.17 write_private_config is broken.  It mixes bytes
+        # and unicode in an os.path.join() call that always fails with a
+        # TypeError.
+        def write_private_config(name, value):
+            privpath = FilePath(config._basedir).descendant([u"private", name])
+            privpath.setContent(value)
 
         if servers_yaml is not None:
             # Provide it a statically configured server to connect to.
-            config.write_private_config(
-                b"servers.yaml",
+            write_private_config(
+                u"servers.yaml",
                 servers_yaml,
             )
         if rootcap:
             config.write_private_config(
-                b"rootcap",
+                u"rootcap",
                 b"dddddddd",
             )
 
         return create_client_from_config(config)
 
+    @given(tahoe_configs())
+    def test_get_root_nodes_rootcap_present(self, get_config):
+        """
+        ``get_root_nodes`` returns a ``list`` of one ``IFilesystemNode`` provider
+        derived from the contents of the *rootcap* private configuration.
+        """
+        d = self._create(get_config, servers_yaml=None, rootcap=True)
+        client_node = extract_result(d)
+        roots = get_root_nodes(client_node, client_node.config)
+        self.assertThat(
+            roots,
+            MatchesAll(
+                HasLength(1),
+                AllMatch(Provides([IFilesystemNode])),
+            ),
+        )
+
+    @given(tahoe_configs())
+    def test_get_root_nodes_rootcap_missing(self, get_config):
+        """
+        ``get_root_nodes`` returns an empty ``list`` if there is no private
+        *rootcap* configuration.
+        """
+        d = self._create(get_config, servers_yaml=None, rootcap=False)
+        client_node = extract_result(d)
+        roots = get_root_nodes(client_node, client_node.config)
+        self.assertThat(
+            roots,
+            Equals([]),
+        )
+
     @settings(
         deadline=None,
     )
@@ -753,7 +779,7 @@ class LoadSigningKeyTests(TestCase):
 
         :param bytes key: A base64-encoded Ristretto signing key.
         """
-        p = FilePath(self.useFixture(TempDir()).join(b"key"))
+        p = FilePath(self.useFixture(TempDir()).join(u"key"))
         p.setContent(key_bytes)
         key = load_signing_key(p)
         self.assertThat(key, IsInstance(SigningKey))
diff --git a/src/_zkapauthorizer/tests/test_pricecalculator.py b/src/_zkapauthorizer/tests/test_pricecalculator.py
index baadd9119d73a37988d90baa5bdb62f106405e73..c1652f2b4bcf4550a141c87615437e36ed3b4b98 100644
--- a/src/_zkapauthorizer/tests/test_pricecalculator.py
+++ b/src/_zkapauthorizer/tests/test_pricecalculator.py
@@ -155,7 +155,7 @@ class PriceCalculatorTests(TestCase):
         self.assertThat(
             price,
             MatchesAll(
-                IsInstance((int, long)),
+                IsInstance(int),
                 GreaterThan(0),
             ),
         )
diff --git a/src/_zkapauthorizer/tests/test_private.py b/src/_zkapauthorizer/tests/test_private.py
index 568cc1eb1baf613c17ee3336874b08b9aed4b17c..ba1fa34deed90454a4360c38464c3dcbe3a66645 100644
--- a/src/_zkapauthorizer/tests/test_private.py
+++ b/src/_zkapauthorizer/tests/test_private.py
@@ -9,8 +9,6 @@
 Tests for ``_zkapauthorizer.private``.
 """
 
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 from allmydata.test.web.matchers import has_response_code
 from testtools import TestCase
 from testtools.matchers import Equals
@@ -39,7 +37,9 @@ class PrivacyTests(TestCase):
     def _authorization(self, scheme, value):
         return Headers(
             {
-                "authorization": ["{} {}".format(scheme, value)],
+                u"authorization": [
+                    u"{} {}".format(scheme.decode("ascii"), value.decode("ascii")),
+                ],
             }
         )
 
@@ -60,7 +60,7 @@ class PrivacyTests(TestCase):
         self.assertThat(
             self.client.head(
                 b"http:///foo/bar",
-                headers=self._authorization("basic", self.token),
+                headers=self._authorization(b"basic", self.token),
             ),
             succeeded(has_response_code(Equals(UNAUTHORIZED))),
         )
@@ -73,7 +73,7 @@ class PrivacyTests(TestCase):
         self.assertThat(
             self.client.head(
                 b"http:///foo/bar",
-                headers=self._authorization(SCHEME, "foo bar"),
+                headers=self._authorization(SCHEME, b"foo bar"),
             ),
             succeeded(has_response_code(Equals(UNAUTHORIZED))),
         )
diff --git a/src/_zkapauthorizer/tests/test_schema.py b/src/_zkapauthorizer/tests/test_schema.py
index 1c53556018b064b4b47855d03242607ab8dd3aba..f0dc6f876a020bfbee26206eb5e3ad88958a1623 100644
--- a/src/_zkapauthorizer/tests/test_schema.py
+++ b/src/_zkapauthorizer/tests/test_schema.py
@@ -17,8 +17,6 @@
 Tests for ``_zkapauthorizer.schema``.
 """
 
-from __future__ import absolute_import
-
 from testtools import TestCase
 from testtools.matchers import Equals
 
diff --git a/src/_zkapauthorizer/tests/test_spending.py b/src/_zkapauthorizer/tests/test_spending.py
index 6833e0895e5d8f4ec38310eec5c25456ccc8e601..42a5d87f5c5d7fd9d860f28445051aa03e270972 100644
--- a/src/_zkapauthorizer/tests/test_spending.py
+++ b/src/_zkapauthorizer/tests/test_spending.py
@@ -62,7 +62,7 @@ class PassGroupTests(TestCase):
             store=configless.store,
         )
 
-        group = pass_factory.get(u"message", num_passes)
+        group = pass_factory.get(b"message", num_passes)
         self.assertThat(
             group,
             MatchesAll(
@@ -97,7 +97,7 @@ class PassGroupTests(TestCase):
         # Figure out some subset, maybe empty, of passes from the group that
         # we will try to operate on.
         group_size = data.draw(integers(min_value=0, max_value=num_passes))
-        indices = range(num_passes)
+        indices = list(range(num_passes))
         random.shuffle(indices)
         spent_indices = indices[:group_size]
 
@@ -106,7 +106,7 @@ class PassGroupTests(TestCase):
             tokens_to_passes=configless.redeemer.tokens_to_passes,
             store=configless.store,
         )
-        group = pass_factory.get(u"message", num_passes)
+        group = pass_factory.get(b"message", num_passes)
         spent, rest = group.split(spent_indices)
         operation(spent)
 
diff --git a/src/_zkapauthorizer/tests/test_storage_client.py b/src/_zkapauthorizer/tests/test_storage_client.py
index 1075884f570d7b15b220f5d33843c5105ecb522f..5a0658b61072c9119f2f518551d1c03d2605bc55 100644
--- a/src/_zkapauthorizer/tests/test_storage_client.py
+++ b/src/_zkapauthorizer/tests/test_storage_client.py
@@ -16,8 +16,6 @@
 Tests for ``_zkapauthorizer._storage_client``.
 """
 
-from __future__ import division
-
 from functools import partial
 
 from allmydata.client import config_from_string
@@ -180,7 +178,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 lambda group: succeed(result),
                 num_passes,
-                partial(pass_factory(integer_passes(num_passes)).get, u"message"),
+                partial(pass_factory(integer_passes(num_passes)).get, b"message"),
             ),
             succeeded(Is(result)),
         )
@@ -197,7 +195,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 lambda group: fail(result),
                 num_passes,
-                partial(pass_factory(integer_passes(num_passes)).get, u"message"),
+                partial(pass_factory(integer_passes(num_passes)).get, b"message"),
             ),
             failed(
                 AfterPreprocessing(
@@ -220,7 +218,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 lambda group: succeed(group.passes),
                 num_passes,
-                partial(passes.get, u"message"),
+                partial(passes.get, b"message"),
             ),
             succeeded(
                 Equals(
@@ -241,7 +239,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 lambda group: None,
                 num_passes,
-                partial(passes.get, u"message"),
+                partial(passes.get, b"message"),
             ),
             succeeded(Always()),
         )
@@ -261,7 +259,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 lambda group: fail(Exception("Anything")),
                 num_passes,
-                partial(passes.get, u"message"),
+                partial(passes.get, b"message"),
             ),
             failed(Always()),
         )
@@ -301,7 +299,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 reject_even_pass_values,
                 num_passes,
-                partial(passes.get, u"message"),
+                partial(passes.get, b"message"),
             ),
             succeeded(Always()),
         )
@@ -342,7 +340,7 @@ class CallWithPassesTests(TestCase):
             call_with_passes(
                 reject_passes,
                 num_passes,
-                partial(passes.get, u"message"),
+                partial(passes.get, b"message"),
             ),
             failed(
                 AfterPreprocessing(
@@ -406,7 +404,7 @@ class CallWithPassesTests(TestCase):
                 # out of passes no matter how many we start with.
                 reject_half_passes,
                 num_passes,
-                partial(passes.get, u"message"),
+                partial(passes.get, b"message"),
             ),
             failed(
                 AfterPreprocessing(
@@ -458,7 +456,7 @@ class PassFactoryTests(TestCase):
         ``IPassGroup.reset`` makes passes available to be returned by
         ``IPassGroup.get`` again.
         """
-        message = u"message"
+        message = b"message"
         min_passes = min(num_passes_a, num_passes_b)
         max_passes = max(num_passes_a, num_passes_b)
 
@@ -486,7 +484,7 @@ class PassFactoryTests(TestCase):
         :param (IPassGroup -> None) invalid_op: Some follow-up operation to
             perform with the pass group and to assert raises an exception.
         """
-        message = u"message"
+        message = b"message"
         factory = pass_factory(integer_passes(num_passes))
         group = factory.get(message, num_passes)
         setup_op(group)
diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py
index 89654633b6b9176ec98fb71492571b0467eb42b7..ceb34deb3d1143a55f8072de31f658992ccce226 100644
--- a/src/_zkapauthorizer/tests/test_storage_protocol.py
+++ b/src/_zkapauthorizer/tests/test_storage_protocol.py
@@ -16,8 +16,6 @@
 Tests for communication between the client and server components.
 """
 
-from __future__ import absolute_import
-
 from allmydata.storage.common import storage_index_to_dir
 from allmydata.storage.shares import get_share_file
 from challenge_bypass_ristretto import PublicKey, random_signing_key
@@ -72,6 +70,7 @@ from .storage_common import (
 )
 from .strategies import bytes_for_share  # Not really a strategy...
 from .strategies import (
+    TestAndWriteVectors,
     lease_cancel_secrets,
     lease_renew_secrets,
     posix_timestamps,
@@ -79,6 +78,7 @@ from .strategies import (
     sharenum_sets,
     sharenums,
     sizes,
+    slot_data_vectors,
     slot_test_and_write_vectors_for_shares,
     storage_indexes,
     write_enabler_secrets,
@@ -1073,6 +1073,73 @@ class ShareTests(TestCase):
             ),
         )
 
+    @given(
+        storage_index=storage_indexes(),
+        secrets=tuples(
+            write_enabler_secrets(),
+            lease_renew_secrets(),
+            lease_cancel_secrets(),
+        ),
+        sharenum=sharenums(),
+        data_vector=slot_data_vectors(),
+        replacement_data_vector=slot_data_vectors(),
+    )
+    def test_test_vectors_match(
+        self, storage_index, secrets, sharenum, data_vector, replacement_data_vector
+    ):
+        """
+        If test vectors are given then the write is allowed if they match the
+        existing data.
+        """
+        empty_test_vector = []
+
+        def write(tw_vectors):
+            return self.client.slot_testv_and_readv_and_writev(
+                storage_index,
+                secrets=secrets,
+                tw_vectors=tw_vectors,
+                r_vector=[],
+            )
+
+        def read(sharenum, readv):
+            d = self.client.slot_readv(storage_index, [sharenum], readv)
+            d.addCallback(lambda data: data[sharenum])
+            return d
+
+        def equal_test_vector(data_vector):
+            return list((offset, len(data), data) for (offset, data) in data_vector)
+
+        # Create the share
+        d = write(
+            {
+                sharenum: (empty_test_vector, data_vector, None),
+            }
+        )
+        self.assertThat(d, is_successful_write())
+
+        # Write some new data with a correct test vector.  We can only be sure
+        # we know data from the last element of the test vector since earlier
+        # elements may have been overwritten so only use that last element in
+        # our test vector.
+        d = write(
+            {
+                sharenum: (
+                    equal_test_vector(data_vector)[-1:],
+                    replacement_data_vector,
+                    None,
+                ),
+            }
+        )
+        self.assertThat(d, is_successful_write())
+
+        # Check that the new data is present
+        assert_read_back_data(
+            self,
+            storage_index,
+            secrets,
+            {sharenum: TestAndWriteVectors(None, replacement_data_vector, None)},
+        )
+
 
 def assert_read_back_data(
     self, storage_index, secrets, test_and_write_vectors_for_shares
diff --git a/src/_zkapauthorizer/tests/test_storage_server.py b/src/_zkapauthorizer/tests/test_storage_server.py
index 7e334d79e6f8fca00f16170d46dca9b2bd1f1513..73a0001fc38d5924d133d09682effa3c20727eb0 100644
--- a/src/_zkapauthorizer/tests/test_storage_server.py
+++ b/src/_zkapauthorizer/tests/test_storage_server.py
@@ -16,8 +16,6 @@
 Tests for ``_zkapauthorizer._storage_server``.
 """
 
-from __future__ import absolute_import, division
-
 from random import shuffle
 from time import time
 
@@ -81,7 +79,7 @@ class ValidationResultTests(TestCase):
         ``validate_passes`` returns a ``_ValidationResult`` instance which
         describes the valid and invalid passes.
         """
-        message = u"hello world"
+        message = b"hello world"
         valid_passes = get_passes(
             message,
             valid_count,
@@ -136,7 +134,7 @@ class ValidationResultTests(TestCase):
                     AfterPreprocessing(
                         str,
                         Equals(
-                            "MorePassesRequired(valid_count=4, required_count=10, signature_check_failed=frozenset([4]))"
+                            "MorePassesRequired(valid_count=4, required_count=10, signature_check_failed=frozenset({4}))",
                         ),
                     ),
                 ),
diff --git a/src/_zkapauthorizer/tests/test_strategies.py b/src/_zkapauthorizer/tests/test_strategies.py
index b046450cc8289561a141b1e1e62b3979d4638692..1184dbbe0951ac80e759e799bd5c57b80e0d481e 100644
--- a/src/_zkapauthorizer/tests/test_strategies.py
+++ b/src/_zkapauthorizer/tests/test_strategies.py
@@ -16,8 +16,6 @@
 Tests for our custom Hypothesis strategies.
 """
 
-from __future__ import absolute_import
-
 from allmydata.client import config_from_string
 from fixtures import TempDir
 from hypothesis import given, note
@@ -49,7 +47,7 @@ class TahoeConfigsTests(TestCase):
         )
         note(config_text)
         config_from_string(
-            tempdir.join(b"tahoe.ini"),
-            b"tub.port",
+            tempdir.join(u"tahoe.ini"),
+            u"tub.port",
             config_text.encode("utf-8"),
         )
diff --git a/tests.nix b/tests.nix
index 40412a752ecaacd918e0aad6bfab37def5f24383..0d7f6a7750c73da64c8f4619503394234497d3f9 100644
--- a/tests.nix
+++ b/tests.nix
@@ -51,9 +51,9 @@ let
       mkdir -p $out
 
       pushd ${zkapauthorizer.src}
-      ${python}/bin/pyflakes src
       ${lint-python}/bin/black --check src
       ${lint-python}/bin/isort --check src
+      ${lint-python}/bin/flake8 src
       popd
 
       ZKAPAUTHORIZER_HYPOTHESIS_PROFILE=${hypothesisProfile'} ${python}/bin/python -m ${if collectCoverage
@@ -70,5 +70,5 @@ let
     '';
 in
 {
-  inherit pkgs python lint-python tests;
+  inherit privatestorage lint-python tests;
 }