diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 65d653ddf94ceaa3435fbaf9a2bb690029f4de4b..8a3b8ec91de583a8cf9195034ef7c75f1a71b5f3 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -41,6 +41,10 @@ from twisted.python.filepath import (
     FilePath,
 )
 
+from .storage_common import (
+    BYTES_PER_PASS,
+    required_passes,
+)
 
 class StoreOpenError(Exception):
     """
@@ -157,6 +161,26 @@ def open_and_initialize(path, required_schema_version, connect=None):
             )
             """,
         )
+        cursor.execute(
+            """
+            CREATE TABLE IF NOT EXISTS [lease-maintenance-spending] (
+                [id] integer, -- A unique identifier for a group of activity.
+                [started] text, -- ISO8601 date+time string when the activity began.
+                [finished] text, -- ISO8601 date+time string when the activity completed (or null).
+
+                -- The number of passes that would be required to renew all
+                -- shares encountered during this activity.  Note that because
+                -- leases on different shares don't necessarily expire at the
+                -- same time this is not necessarily the number of passes
+                -- **actually** used during this activity.  Some shares may
+                -- not have required lease renewal.  Also note that while the
+                -- activity is ongoing this value may change.
+                [count] integer,
+
+                PRIMARY KEY([id])
+            )
+            """,
+        )
     return conn
 
 
@@ -427,6 +451,140 @@ class VoucherStore(object):
             u"unblinded-tokens": list(token for (token,) in tokens),
         }
 
+    def start_lease_maintenance(self):
+        """
+        Get an object which can track a newly started round of lease maintenance
+        activity.
+
+        :return LeaseMaintenance: A new, started lease maintenance object.
+        """
+        m = LeaseMaintenance(self.now, self._connection)
+        m.start()
+        return m
+
+    @with_cursor
+    def get_latest_lease_maintenance_activity(self, cursor):
+        """
+        Get a description of the most recently completed lease maintenance
+        activity.
+
+        :return LeaseMaintenanceActivity|None: If any lease maintenance has
+            completed, an object describing its results.  Otherwise, None.
+        """
+        cursor.execute(
+            """
+            SELECT [started], [count], [finished]
+            FROM [lease-maintenance-spending]
+            WHERE [finished] IS NOT NULL
+            ORDER BY [finished] DESC
+            LIMIT 1
+            """,
+        )
+        activity = cursor.fetchall()
+        if len(activity) == 0:
+            return None
+        [(started, count, finished)] = activity
+        return LeaseMaintenanceActivity(
+            parse_datetime(started, delimiter=u" "),
+            count,
+            parse_datetime(finished, delimiter=u" "),
+        )
+
+
+@attr.s
+class LeaseMaintenance(object):
+    """
+    A state-updating helper for recording pass usage during a lease
+    maintenance run.
+
+    Get one of these from ``VoucherStore.start_lease_maintenance``.  Then use
+    the ``observe`` and ``finish`` methods to persist state about a lease
+    maintenance run.
+
+    :ivar _now: A no-argument callable which returns a datetime giving a time
+        to use as current.
+
+    :ivar _connection: A SQLite3 connection object to use to persist observed
+        information.
+
+    :ivar _rowid: None for unstarted lease maintenance objects.  For started
+        objects, the database row id that corresponds to the started run.
+        This is used to make sure future updates go to the right row.
+    """
+    _now = attr.ib()
+    _connection = attr.ib()
+    _rowid = attr.ib(default=None)
+
+    @with_cursor
+    def start(self, cursor):
+        """
+        Record the start of a lease maintenance run.
+        """
+        if self._rowid is not None:
+            raise Exception("Cannot re-start a particular _LeaseMaintenance.")
+
+        cursor.execute(
+            """
+            INSERT INTO [lease-maintenance-spending] ([started], [finished], [count])
+            VALUES (?, ?, ?)
+            """,
+            (self._now(), None, 0),
+        )
+        self._rowid = cursor.lastrowid
+
+    @with_cursor
+    def observe(self, cursor, size):
+        """
+        Record a storage object of a given size.
+        """
+        # XXX The client has no idea how many shares a server is storing and
+        # can do nothing except renew the lease on all of them. :( Here, guess
+        # that there is only one share.  "Servers of happiness" should make
+        # this more likely to be the case...  We might be underestimating
+        # storage costs though.
+        count = required_passes(BYTES_PER_PASS, {size})
+        cursor.execute(
+            """
+            UPDATE [lease-maintenance-spending]
+            SET [count] = [count] + ?
+            WHERE [id] = ?
+            """,
+            (count, self._rowid),
+        )
+
+    @with_cursor
+    def finish(self, cursor):
+        """
+        Record the completion of this lease maintenance run.
+        """
+        cursor.execute(
+            """
+            UPDATE [lease-maintenance-spending]
+            SET [finished] = ?
+            WHERE [id] = ?
+            """,
+            (self._now(), self._rowid),
+        )
+        self._rowid = None
+
+
+@attr.s
+class LeaseMaintenanceActivity(object):
+    started = attr.ib()
+    passes_required = attr.ib()
+    finished = attr.ib()
+
+
+# store = ...
+# x = store.start_lease_maintenance()
+# x.observe(size=123)
+# x.observe(size=456)
+# ...
+# x.finish()
+#
+# x = store.get_latest_lease_maintenance_activity()
+# xs.started, xs.passes_required, xs.finished
+
 
 @attr.s(frozen=True)
 class UnblindedToken(object):
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index d31e669a9f2fd108fa67e25aa655ef747707e246..018eb07f20fc7e526b7d8b30305566c93ec0e29c 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -27,6 +27,9 @@ from os import (
 from errno import (
     EACCES,
 )
+from datetime import (
+    timedelta,
+)
 
 from testtools import (
     TestCase,
@@ -52,7 +55,9 @@ from hypothesis import (
 from hypothesis.strategies import (
     data,
     lists,
+    tuples,
     datetimes,
+    timedeltas,
     integers,
 )
 
@@ -60,6 +65,10 @@ from twisted.python.filepath import (
     FilePath,
 )
 
+from ..storage_common import (
+    BYTES_PER_PASS,
+)
+
 from ..model import (
     SchemaError,
     StoreOpenError,
@@ -68,6 +77,7 @@ from ..model import (
     Pending,
     DoubleSpend,
     Redeemed,
+    LeaseMaintenanceActivity,
     open_and_initialize,
     memory_connect,
 )
@@ -78,6 +88,7 @@ from .strategies import (
     voucher_objects,
     random_tokens,
     unblinded_tokens,
+    posix_safe_datetimes,
 )
 from .fixtures import (
     TemporaryVoucherStore,
@@ -217,7 +228,6 @@ class VoucherStoreTests(TestCase):
         If the underlying database file cannot be opened then
         ``VoucherStore.from_node_config`` raises ``StoreOpenError``.
         """
-
         tempdir = self.useFixture(TempDir())
         nodedir = tempdir.join(b"node")
 
@@ -238,6 +248,73 @@ class VoucherStoreTests(TestCase):
         )
 
 
+class LeaseMaintenanceTests(TestCase):
+    """
+    Tests for the lease-maintenance related parts of ``VoucherStore``.
+    """
+    @given(
+        tahoe_configs(),
+        posix_safe_datetimes(),
+        lists(
+            tuples(
+                # How much time passes before this activity starts
+                timedeltas(min_value=timedelta(0), max_value=timedelta(days=1)),
+                # Some activity.  This list of two tuples gives us a trivial
+                # way to compute the total passes required (just sum the pass
+                # counts in it).  This is nice because it avoids having the
+                # test re-implement size quantization which would just be
+                # repeated code duplicating the implementation.  The second
+                # value lets us fuzz the actual size values a little bit in a
+                # way which shouldn't affect the passes required.
+                lists(
+                    tuples(
+                        # The activity itself, in pass count
+                        integers(min_value=1, max_value=2 ** 16 - 1),
+                        # Amount by which to trim back the share sizes
+                        integers(min_value=0, max_value=BYTES_PER_PASS - 1),
+                    ),
+                ),
+                # How much time passes before this activity finishes
+                timedeltas(min_value=timedelta(0), max_value=timedelta(days=1)),
+            ),
+        ),
+    )
+    def test_lease_maintenance_activity(self, get_config, now, activity):
+        """
+        ``VoucherStore.get_latest_lease_maintenance_activity`` returns a
+        ``LeaseMaintenanceTests`` with fields reflecting the most recently
+        finished lease maintenance activity.
+        """
+        store = self.useFixture(
+            TemporaryVoucherStore(get_config, lambda: now),
+        ).store
+
+        expected = None
+        for (start_delay, sizes, finish_delay) in activity:
+            now += start_delay
+            started = now
+            x = store.start_lease_maintenance()
+            passes_required = 0
+            for (num_passes, trim_size) in sizes:
+                passes_required += num_passes
+                x.observe(num_passes * BYTES_PER_PASS - trim_size)
+            now += finish_delay
+            x.finish()
+            finished = now
+
+            # Let the last iteration of the loop define the expected value.
+            expected = LeaseMaintenanceActivity(
+                started,
+                passes_required,
+                finished,
+            )
+
+        self.assertThat(
+            store.get_latest_lease_maintenance_activity(),
+            Equals(expected),
+        )
+
+
 class VoucherTests(TestCase):
     """
     Tests for ``Voucher``.