Summary
scheduleStateEnforcement in nitronode/store/database/blockchain_action.go unconditionally inserts a new blockchain_actions row via db.Create, with no dedup on (state_id, action_type) or (state_id, blockchain_id, action_type). Same-event replay enqueues a duplicate Pending action.
Hazard
When an event handler that schedules an action (most notably HandleEscrowDepositInitiated → ScheduleInitiateEscrowDeposit) receives an equal-version replay — which can happen on indexer replay or chain reorg replay — a second blockchain_actions row is inserted for the same (state_id, action_type).
The action worker (GetActions, same file) filters by status=Pending and picks up both rows. The second broadcast reverts on chain (the contract state-machine rejects re-initialization of the same state), but the Node records the duplicate as a failed blockchain_actions row that an operator must inspect.
Why this is exposed
The event-handler version guards in nitronode/event_handlers/service.go (helper guardEventVersionMonotonic) use strict < (not <=) and deliberately admit equal-version replays as a no-op, because legitimate indexer-replay and reorg-replay re-deliver identical (channelId, stateVersion) pairs. Most of the handler body is idempotent on that replay path (UpdateChannel, RefreshUserEnforcedBalance, UpdateStateSigsIfMissing). Scheduling is the one side effect that is not idempotent — hence this gap.
Proposed fix
- Add a unique partial index on
(state_id, action_type) for status=Pending rows.
- Change
scheduleStateEnforcement to use INSERT ... ON CONFLICT DO NOTHING (or the GORM equivalent: clause.OnConflict{DoNothing: true}).
This makes the schedule path fully idempotent across the replay chain — the complement to the existing < (not <=) version guard in the event handlers.
Files involved
nitronode/store/database/blockchain_action.go (~L102–113) — the unconditional db.Create.
nitronode/store/database/ — wherever the model migrations live, for the unique-index migration.
Operational impact today
Low — duplicate broadcasts are bounded (one per replay event), revert on chain (gas cost only, no state corruption), and surface as visible failed blockchain_actions rows. No user funds at risk. This is a hygiene issue, not a safety issue.
Labels
area/nitronode, type/tech-debt, priority/low
Summary
scheduleStateEnforcementinnitronode/store/database/blockchain_action.gounconditionally inserts a newblockchain_actionsrow viadb.Create, with no dedup on(state_id, action_type)or(state_id, blockchain_id, action_type). Same-event replay enqueues a duplicate Pending action.Hazard
When an event handler that schedules an action (most notably
HandleEscrowDepositInitiated→ScheduleInitiateEscrowDeposit) receives an equal-version replay — which can happen on indexer replay or chain reorg replay — a secondblockchain_actionsrow is inserted for the same(state_id, action_type).The action worker (
GetActions, same file) filters bystatus=Pendingand picks up both rows. The second broadcast reverts on chain (the contract state-machine rejects re-initialization of the same state), but the Node records the duplicate as a failedblockchain_actionsrow that an operator must inspect.Why this is exposed
The event-handler version guards in
nitronode/event_handlers/service.go(helperguardEventVersionMonotonic) use strict<(not<=) and deliberately admit equal-version replays as a no-op, because legitimate indexer-replay and reorg-replay re-deliver identical(channelId, stateVersion)pairs. Most of the handler body is idempotent on that replay path (UpdateChannel,RefreshUserEnforcedBalance,UpdateStateSigsIfMissing). Scheduling is the one side effect that is not idempotent — hence this gap.Proposed fix
(state_id, action_type)forstatus=Pendingrows.scheduleStateEnforcementto useINSERT ... ON CONFLICT DO NOTHING(or the GORM equivalent:clause.OnConflict{DoNothing: true}).This makes the schedule path fully idempotent across the replay chain — the complement to the existing
<(not<=) version guard in the event handlers.Files involved
nitronode/store/database/blockchain_action.go(~L102–113) — the unconditionaldb.Create.nitronode/store/database/— wherever the model migrations live, for the unique-index migration.Operational impact today
Low — duplicate broadcasts are bounded (one per replay event), revert on chain (gas cost only, no state corruption), and surface as visible failed
blockchain_actionsrows. No user funds at risk. This is a hygiene issue, not a safety issue.Labels
area/nitronode,type/tech-debt,priority/low