Skip to content

Handle out-of-order intent lifecycle events in the projector #1

Description

@chenshj73

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

  1. Deliver IntentFinalized or IntentRefunded for intent H to the projector before IntentOpened(H).
  2. The status update affects zero intents rows and continues.
  3. Deliver IntentOpened(H).
  4. The projector inserts H with status: "open".
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions