Skip to content

Commit 530e44a

Browse files
MoonBoi9001claude
andcommitted
feat(offer): make Offer entity immutable and idempotent
The Offer entity was previously mutable with "latest offer hash wins" semantics, which didn't match how the entity is actually used. For a given agreementId, the RCA identifying fields (payer, dataService, serviceProvider, deadline, nonce) are fixed by the id derivation, so any duplicate OfferStored event for the same id carries the same offerHash by construction. There is nothing meaningful to overwrite. Switch the entity to @entity(immutable: true) and early-return from handleOfferStored when an entity with the same id already exists. Writing twice to an immutable entity would halt the subgraph, so the guard is load-bearing against dipper crash-recovery re-submissions and chain reorg re-emissions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b180a5d commit 530e44a

2 files changed

Lines changed: 21 additions & 13 deletions

File tree

schema.graphql

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,17 @@ type OfferStored @entity(immutable: true) {
7474
transactionHash: Bytes!
7575
}
7676

77-
# Latest stored offer per agreementId, keyed by bytes16 agreement ID.
78-
# indexer-service queries this entity to verify an RCA offer exists on-chain
79-
# before accepting a DIPs proposal with an empty signature. The offer path
80-
# (`RecurringCollector.accept(rca, "")`) does not emit an event on cleanup,
81-
# so this entity is not updated after accept -- downstream consumers must
82-
# cross-reference IndexingAgreementAccepted if they need "still pending" semantics.
83-
type Offer @entity(immutable: false) {
77+
# First stored offer per agreementId, keyed by bytes16 agreement ID.
78+
# Dipper queries this entity as an idempotency gate -- avoids re-submitting
79+
# an offer after a crashed-mid-flight restart where the on-chain tx landed
80+
# but dipper lost track of it.
81+
#
82+
# Declared immutable because, for a given agreementId, the RCA identifying
83+
# fields (payer, dataService, serviceProvider, deadline, nonce) are fixed by
84+
# the id derivation, so any duplicate OfferStored event for the same id would
85+
# carry the same offerHash. The handler enforces this by returning early on
86+
# the second event instead of attempting to overwrite.
87+
type Offer @entity(immutable: true) {
8488
id: Bytes!
8589
payer: Bytes!
8690
offerType: Int!

src/recurring-collector.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ export function handleOfferStored(event: OfferStoredEvent): void {
1414
log.transactionHash = event.transaction.hash
1515
log.save()
1616

17-
// Latest-offer entity keyed by agreementId (bytes16). Overwrites any prior
18-
// offer for the same agreement -- the newest stored offer hash wins.
19-
// indexer-service queries this entity by id to verify offer existence.
20-
let offer = Offer.load(event.params.agreementId)
21-
if (offer == null) {
22-
offer = new Offer(event.params.agreementId)
17+
// First-offer entity keyed by agreementId (bytes16). Immutable: if an
18+
// entity already exists, a duplicate OfferStored event for the same
19+
// agreement id (e.g. dipper crashed and re-submitted, or a chain reorg
20+
// re-emitted) carries the same offerHash by construction and we return
21+
// early. Writing to an immutable entity a second time is a graph-node
22+
// error that would halt the subgraph, so the guard is load-bearing.
23+
let existing = Offer.load(event.params.agreementId)
24+
if (existing != null) {
25+
return
2326
}
27+
let offer = new Offer(event.params.agreementId)
2428
offer.payer = event.params.payer
2529
offer.offerType = event.params.offerType
2630
offer.offerHash = event.params.offerHash

0 commit comments

Comments
 (0)