Skip to content

Commit 427ad64

Browse files
committed
Fail an attestation batch that never comes back
1 parent aa14418 commit 427ad64

2 files changed

Lines changed: 88 additions & 12 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { withTimeout, DEFAULT_SUBMIT_TIMEOUT_MS } from './attestor';
3+
4+
describe('attestation submit deadline', () => {
5+
it('fails a submission that never comes back', async () => {
6+
// The failure that stopped 2,476 receipts: `transaction.wait()` waits for a
7+
// receipt with no deadline, so a provider that stops answering leaves the
8+
// job active forever, and the sweeper only clears jobs that finished.
9+
const never = new Promise<string[]>(() => {});
10+
await expect(withTimeout(never, 20, 'attestation batch of 50')).rejects.toThrow(
11+
/attestation batch of 50 did not settle within 20ms/,
12+
);
13+
});
14+
15+
it('leaves a submission that does come back alone', async () => {
16+
await expect(withTimeout(Promise.resolve(['0xuid']), 1_000, 'batch')).resolves.toEqual([
17+
'0xuid',
18+
]);
19+
});
20+
21+
it('waits minutes rather than seconds, because a batch is not a request', async () => {
22+
// Base blocks every two seconds. A deadline near the block time would fail
23+
// healthy batches and re-attest them, which costs real money.
24+
expect(DEFAULT_SUBMIT_TIMEOUT_MS).toBeGreaterThanOrEqual(60_000);
25+
});
26+
});

apps/backend/src/attest/attestor.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

137139
export 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

Comments
 (0)