@@ -132,6 +132,8 @@ export interface EasSubmitterOptions {
132132 readonly privateKey : string ;
133133 readonly rpcUrl : string ;
134134 readonly easAddress ?: string ;
135+ /** How long one batch may take before it is treated as lost. */
136+ readonly timeoutMs ?: number ;
135137}
136138
137139export class MissingAttesterKeyError extends Error { }
@@ -161,24 +163,72 @@ export function createEasSubmitter(
161163 ) ;
162164 }
163165
166+ const timeoutMs = options ?. timeoutMs ?? Number ( env . ATTEST_SUBMIT_TIMEOUT_MS ?? DEFAULT_SUBMIT_TIMEOUT_MS ) ;
167+
164168 return {
165169 async submit ( requests ) {
166170 const provider = new ethers . JsonRpcProvider ( rpcUrl ) ;
167171 const eas = new EAS ( easAddress ) ;
168172 eas . connect ( new ethers . Wallet ( privateKey , provider ) ) ;
169173
170- const transaction = await eas . multiAttest ( [
171- {
172- schema : schemaUid ,
173- data : requests . map ( ( request ) => ( {
174- recipient : request . recipient ,
175- expirationTime : NO_EXPIRATION ,
176- revocable : true ,
177- data : request . encodedData ,
178- } ) ) ,
179- } ,
180- ] ) ;
181- return transaction . wait ( ) ;
174+ return withTimeout (
175+ ( async ( ) => {
176+ const transaction = await eas . multiAttest ( [
177+ {
178+ schema : schemaUid ,
179+ data : requests . map ( ( request ) => ( {
180+ recipient : request . recipient ,
181+ expirationTime : NO_EXPIRATION ,
182+ revocable : true ,
183+ data : request . encodedData ,
184+ } ) ) ,
185+ } ,
186+ ] ) ;
187+ return transaction . wait ( ) ;
188+ } ) ( ) ,
189+ timeoutMs ,
190+ `attestation batch of ${ requests . length } ` ,
191+ ) ;
182192 } ,
183193 } ;
184194}
195+
196+ /**
197+ * How long a batch may take before it is treated as lost.
198+ *
199+ * Generous on purpose: Base produces a block every two seconds, so a batch that
200+ * has not settled in three minutes is not slow, it is gone.
201+ */
202+ export const DEFAULT_SUBMIT_TIMEOUT_MS = 180_000 ;
203+
204+ /**
205+ * Fails a submission that never comes back.
206+ *
207+ * `transaction.wait()` waits for a receipt with no deadline of its own, so a
208+ * provider that stops answering leaves the promise pending forever. That is
209+ * worse than an error here: the attest job stays *active* rather than failing,
210+ * and [queue/attest.sweeper.ts](../queue/attest.sweeper.ts) only clears jobs
211+ * that finished, so every later re-ask is skipped and the whole corpus stops
212+ * being attested in silence. Observed on 2026-09-09, where 2,476 receipts sat
213+ * behind one call that never returned.
214+ *
215+ * Rejecting instead is safe and self-healing: the job fails, the failure is
216+ * removed, the sweeper asks again, and the work queue is still
217+ * `attestation_uid IS NULL`, so nothing already onchain is re-attested. The one
218+ * cost is a batch whose transaction landed while the wait timed out, which is
219+ * re-attested and paid for twice; at three minutes that is a rarity, and it is
220+ * a far smaller bill than an attester that stops.
221+ */
222+ export async function withTimeout < T > ( work : Promise < T > , ms : number , what : string ) : Promise < T > {
223+ let timer : ReturnType < typeof setTimeout > | undefined ;
224+ try {
225+ return await Promise . race ( [
226+ work ,
227+ new Promise < never > ( ( _resolve , reject ) => {
228+ timer = setTimeout ( ( ) => reject ( new Error ( `${ what } did not settle within ${ ms } ms` ) ) , ms ) ;
229+ } ) ,
230+ ] ) ;
231+ } finally {
232+ clearTimeout ( timer ) ;
233+ }
234+ }
0 commit comments