diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index c0d5536009d07976a85e74109cd3ec5347d5e302..ec72c8d740b6548facfd5a98f2a09e100ed03c5c 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -33,6 +33,8 @@ from twisted.internet.defer import (
     succeed,
 )
 
+import privacypass
+
 from .foolscap import (
     TOKEN_LENGTH,
 )
@@ -138,6 +140,27 @@ class DummyRedeemer(object):
         )
 
 
+@implementer(IRedeemer)
+@attr.s
+class RistrettoRedeemer(object):
+    _agent = attr.ib()
+
+    def random_tokens_for_voucher(self, voucher, count):
+        return list(
+            RandomToken(privacypass.RandomToken.create().encode_base64().decode("ascii"))
+            for n
+            in range(count)
+        )
+
+    def redeem(self, voucher, random_tokens):
+        # The wrong implementation, of course.
+        return succeed(list(
+            Pass(text=u"tok-" + token.token_value)
+            for token
+            in random_tokens
+        ))
+
+
 @attr.s
 class PaymentController(object):
     """
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 2969a01187eb56dfa3cec435846f4d021c40f7a6..318d96426bbd2496fb36b11055a14a53b7fe0918 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -16,11 +16,25 @@
 Tests for ``_zkapauthorizer.controller``.
 """
 
+from json import (
+    loads,
+    dumps,
+)
+from zope.interface import (
+    implementer,
+)
 from testtools import (
     TestCase,
 )
 from testtools.matchers import (
     Equals,
+    MatchesAll,
+    AllMatch,
+    IsInstance,
+    HasLength,
+)
+from testtools.twistedsupport import (
+    succeeded,
 )
 
 from fixtures import (
@@ -30,22 +44,42 @@ from fixtures import (
 from hypothesis import (
     given,
 )
-
+from hypothesis.strategies import (
+    integers,
+)
+from twisted.internet.defer import (
+    fail,
+)
+from twisted.web.iweb import (
+    IAgent,
+)
+from twisted.web.resource import (
+    Resource,
+)
+from treq.testing import (
+    RequestTraversalAgent,
+)
 from ..controller import (
+    IRedeemer,
     NonRedeemer,
     DummyRedeemer,
+    RistrettoRedeemer,
     PaymentController,
 )
 
 from ..model import (
     memory_connect,
     VoucherStore,
+    Pass,
 )
 
 from .strategies import (
     tahoe_configs,
     vouchers,
 )
+from .matchers import (
+    Provides,
+)
 
 class PaymentControllerTests(TestCase):
     """
@@ -98,3 +132,99 @@ class PaymentControllerTests(TestCase):
             persisted_voucher.redeemed,
             Equals(True),
         )
+
+
+class RistrettoRedeemerTests(TestCase):
+    """
+    Tests for ``RistrettoRedeemer``.
+    """
+    def test_interface(self):
+        """
+        An ``RistrettoRedeemer`` instance provides ``IRedeemer``.
+        """
+        redeemer = RistrettoRedeemer(stub_agent())
+        self.assertThat(
+            redeemer,
+            Provides([IRedeemer]),
+        )
+
+    @given(vouchers(), integers(min_value=1, max_value=100))
+    def test_redemption(self, voucher, num_tokens):
+        """
+        ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a list
+        of ``Pass`` instances.
+        """
+        public_key = u"pub foo-bar"
+        signatures = list(u"sig-{}".format(n) for n in range(num_tokens))
+        proof = u"proof bar-foo"
+
+        issuer = SuccessfulRedemption(public_key, signatures, proof)
+        agent = agent_for_loopback_ristretto(issuer)
+        redeemer = RistrettoRedeemer(agent)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        # The redeemer gives back the requested number of tokens.
+        self.expectThat(
+            len(random_tokens),
+            Equals(num_tokens),
+        )
+        d = redeemer.redeem(
+            voucher,
+            random_tokens,
+        )
+        # Perform some very basic checks on the results.  We won't verify the
+        # crypto here since we don't have a real Ristretto server.  Such
+        # checks would fail.  Some integration tests will verify that part of
+        # things.
+        self.assertThat(
+            d,
+            succeeded(
+                MatchesAll(
+                    AllMatch(
+                        IsInstance(Pass),
+                    ),
+                    HasLength(num_tokens),
+                ),
+            ),
+        )
+
+
+def agent_for_loopback_ristretto(local_issuer):
+    """
+    Create an ``IAgent`` which can dispatch to a local issuer.
+    """
+    v1 = Resource()
+    v1.putChild(b"redeem", local_issuer)
+    root = Resource()
+    root.putChild(b"v1", v1)
+    return RequestTraversalAgent(root)
+
+
+class SuccessfulRedemption(Resource):
+    def __init__(self, public_key, signatures, proof):
+        Resource.__init__(self)
+        self.public_key = public_key
+        self.signatures = signatures
+        self.proof = proof
+        self.redemptions = []
+
+    def render_POST(self, request):
+        request_body = loads(request.content.read())
+        voucher = request_body[u"redeemVoucher"]
+        tokens = request_body[u"redeemTokens"]
+        self.redemptions.append((voucher, tokens))
+        return dumps({
+            u"success": True,
+            u"public-key": self.public_key,
+            u"signatures": self.signatures,
+            u"proof": self.proof,
+        })
+
+
+@implementer(IAgent)
+class _StubAgent(object):
+    def request(self, method, uri, headers=None, bodyProducer=None):
+        return fail(Exception("It's only a model."))
+
+
+def stub_agent():
+    return _StubAgent()