What happens. The lookup bound of 16 records and 4 s is meant to apply to one write. For applyWrites, the server prefetches separately for each write in the batch, so the bound applies once per write. A 200-write batch where each record names 16 made-up DIDs starts up to 3,200 outbound lookups. Each lookup is a DID document fetch plus a listRecords. Nothing charges the caller first, and a batch the gate refuses still makes all its lookups.
How to reproduce.
Reader::prefetchloops over the writes and callsself.foreign.prefetch(&references).awaitonce for each (crates/didbot-serve/src/lookups.rs:148-157).- Each call starts its own budget (
crates/didbot-lookup/src/lib.rs:396) and its own cap,take(self.limits.per_write)(lib.rs:398). The doc atlib.rs:169says this bounds "what a single write can cost somebody else's server". - The batch route passes up to
MAX_APPLY_WRITES(200) writes into one call (crates/didbot-serve/src/routes/repo.rs:661-670). The write meter charges only insideadmit, after the gate. - The 3,200 distinct answers also push everything out of the 1,024-entry lookup cache that every other write relies on.
Why it matters. Any write token turns the server into a request reflector. Records naming did:web:victim.example:uN aim thousands of HTTPS requests from the server's address at one host. Made-up did:plc values aim them at plc.directory, which the server needs for its own DID resolution.
Fixed in #1247. A write or a whole batch makes one lookup call with one deadline, fetching at most
[capacity] foreign_lookups_per_requestrecords (default 64, cached ones free). In the test, a 200-write batch naming 3,200 accounts on one host makes at most 64 requests of it, and an account cached before the batch is still cached after.