Hi Xebra team, I found a lifecycle projection edge case while reading the projector. A terminal/advanced intent event can be permanently lost if it is delivered before its IntentOpened row.
Impact
The projector already notes that Kafka does not guarantee per-intent ordering. However, the current status update is a plain UPDATE intents ... WHERE intent_hash = .... If the intent row does not exist yet, Drizzle/SQL updates zero rows and does not throw, so the catch block is not reached. When IntentOpened is later projected, projectIntentOpened() inserts the row with status: "open". The earlier IntentClaimed, IntentFinalized, or IntentRefunded event remains only in escrow_events; the intents.status row can stay stale forever.
That gives the UI/API/arbiter-facing projection a different lifecycle state from the escrow contract: for example, an already finalized or refunded intent may still appear open.
Code
apps/projector/src/projector.ts:25-30 maps lifecycle events into intent statuses:
const STATUS_BY_EVENT_TYPE: Partial<Record<ChainEvent["eventType"], string>> = {
IntentClaimed: "claimed",
IntentChallenged: "challenged",
IntentFinalized: "finalized",
IntentRefunded: "refunded",
};
apps/projector/src/projector.ts:32-43 inserts IntentOpened rows with conflict-ignore semantics:
export async function projectEvent(db: Database, event: ChainEvent, logger: Logger): Promise<void> {
if (event.eventType === "IntentOpened") {
const row = projectIntentOpened(event);
if (!row) {
logger.warn(
{ eventId: event.id },
"projector: IntentOpened event didn't match a known payload shape, skipping",
);
return;
}
await db.insert(intents).values(row).onConflictDoNothing();
}
apps/projector/src/projector.ts:77-94 tries to apply later lifecycle statuses, but a missing row is not treated as a retryable condition:
const newStatus = STATUS_BY_EVENT_TYPE[event.eventType];
if (newStatus && event.intentHash) {
try {
await db
.update(intents)
.set({ status: newStatus as (typeof intents.$inferSelect)["status"] })
.where(eq(intents.intentHash, event.intentHash));
} catch (err) {
// The intent row may not exist yet if this event was delivered out of order relative to
// its IntentOpened event (Kafka partitions by event id, not intentHash, so strict
// per-intent ordering isn't guaranteed — see this file's module doc comment) — log and
// move on rather than crash the consumer over a transient ordering race.
logger.warn(
{ eventId: event.id, err },
"projector: status update failed, possibly out-of-order delivery",
);
}
}
apps/projector/src/project-intent-opened.ts:46-63 and :83-100 always create the row as open:
return {
intentHash: event.intentHash as string,
corridorId: corridorId(ChainId.ArcEvm, ChainId.Stellar),
user: { chainId: ChainId.ArcEvm, encoding: AddrEncoding.EvmAddress20, raw: p.user },
sourceAssetId: p.sourceToken,
sourceAmount: p.sourceAmount,
destAssetId: p.destAsset,
minDestAmount: p.minDestAmount,
destAddress: {
chainId: ChainId.Stellar,
encoding: AddrEncoding.StellarEd25519_32,
raw: p.destAddress,
},
expiry: new Date(Number(p.expiry) * 1000),
nonce: p.nonce,
status: "open",
rawIntent: p,
};
return {
intentHash: event.intentHash as string,
corridorId: corridorId(ChainId.Stellar, destChain as ChainId),
user: { chainId: ChainId.Stellar, encoding: AddrEncoding.StellarEd25519_32, raw: p.user },
sourceAssetId: p.source_token,
sourceAmount: p.source_amount,
destAssetId: p.dest_asset,
minDestAmount: p.min_dest_amount,
destAddress: {
chainId: destChain as ChainId,
encoding: AddrEncoding.SolanaEd25519_32,
raw: p.dest_address,
},
expiry: new Date(Number(p.expiry) * 1000),
nonce: p.nonce,
status: "open",
rawIntent: p,
};
On-chain, these are real lifecycle transitions. contracts/arc-evm/src/XebraEscrow.sol:116-127:
event IntentClaimed(
bytes32 indexed intentHash,
address indexed solver,
bytes32 stellarTxHash,
uint256 deliveredAmount,
uint256 solverBond,
uint64 challengeDeadline
);
event IntentChallenged(bytes32 indexed intentHash, address indexed challenger, uint256 challengerBond);
event IntentResolved(bytes32 indexed intentHash, bool claimValid);
event IntentFinalized(bytes32 indexed intentHash, address indexed solver, uint256 sourceAmount);
event IntentRefunded(bytes32 indexed intentHash, address indexed to, uint256 sourceAmount);
contracts/arc-evm/src/XebraEscrow.sol:227-236:
e.status = Status.Claimed;
e.solver = msg.sender;
e.stellarTxHash = stellarTxHash;
e.deliveredAmount = deliveredAmount;
e.solverBond = msg.value;
e.claimedAt = uint64(block.timestamp);
emit IntentClaimed(
intentHash, msg.sender, stellarTxHash, deliveredAmount, msg.value, uint64(block.timestamp) + uint64(challengeWindow)
);
contracts/arc-evm/src/XebraEscrow.sol:311-317:
e.status = Status.Finalized;
e.solverBond = 0;
IERC20(sourceToken).safeTransfer(solver, sourceAmount);
_sendNative(solver, bond);
emit IntentFinalized(intentHash, solver, sourceAmount);
contracts/arc-evm/src/XebraEscrow.sol:331-335:
e.status = Status.Refunded;
IERC20(sourceToken).safeTransfer(user, sourceAmount);
emit IntentRefunded(intentHash, user, sourceAmount);
Reproduction sketch
- Deliver
IntentFinalized or IntentRefunded for intent H to the projector before IntentOpened(H).
- The status update affects zero
intents rows and continues.
- Deliver
IntentOpened(H).
- The projector inserts
H with status: "open".
- No later event necessarily replays the finalized/refunded status, so the materialized row remains stale.
Suggested fix
Make the projection monotonic and out-of-order tolerant. For example:
- Treat zero-row status updates as pending work rather than success.
- Upsert a placeholder intent status and let
IntentOpened merge details without resetting the latest lifecycle status.
- Or, when inserting
IntentOpened, derive the latest status from escrow_events for that intentHash.
- Add a regression test where
IntentFinalized/IntentRefunded is projected before IntentOpened.
Thanks for the project. This is exactly the kind of distributed intent-lifecycle edge case that is easy to miss because each local handler looks reasonable in isolation.
Hi Xebra team, I found a lifecycle projection edge case while reading the projector. A terminal/advanced intent event can be permanently lost if it is delivered before its
IntentOpenedrow.Impact
The projector already notes that Kafka does not guarantee per-intent ordering. However, the current status update is a plain
UPDATE intents ... WHERE intent_hash = .... If the intent row does not exist yet, Drizzle/SQL updates zero rows and does not throw, so thecatchblock is not reached. WhenIntentOpenedis later projected,projectIntentOpened()inserts the row withstatus: "open". The earlierIntentClaimed,IntentFinalized, orIntentRefundedevent remains only inescrow_events; theintents.statusrow can stay stale forever.That gives the UI/API/arbiter-facing projection a different lifecycle state from the escrow contract: for example, an already finalized or refunded intent may still appear open.
Code
apps/projector/src/projector.ts:25-30maps lifecycle events into intent statuses:apps/projector/src/projector.ts:32-43insertsIntentOpenedrows with conflict-ignore semantics:apps/projector/src/projector.ts:77-94tries to apply later lifecycle statuses, but a missing row is not treated as a retryable condition:apps/projector/src/project-intent-opened.ts:46-63and:83-100always create the row as open:On-chain, these are real lifecycle transitions.
contracts/arc-evm/src/XebraEscrow.sol:116-127:contracts/arc-evm/src/XebraEscrow.sol:227-236:contracts/arc-evm/src/XebraEscrow.sol:311-317:contracts/arc-evm/src/XebraEscrow.sol:331-335:Reproduction sketch
IntentFinalizedorIntentRefundedfor intentHto the projector beforeIntentOpened(H).intentsrows and continues.IntentOpened(H).Hwithstatus: "open".Suggested fix
Make the projection monotonic and out-of-order tolerant. For example:
IntentOpenedmerge details without resetting the latest lifecycle status.IntentOpened, derive the latest status fromescrow_eventsfor thatintentHash.IntentFinalized/IntentRefundedis projected beforeIntentOpened.Thanks for the project. This is exactly the kind of distributed intent-lifecycle edge case that is easy to miss because each local handler looks reasonable in isolation.