Skip to content

createActionsList cannot detect a dropped block: no check that actionStateOne[i] === actionStateTwo[i+1] #2905

Description

@dkijania

Summary

createActionsList validates each returned block in isolation and never checks that
consecutive blocks link. If an archive endpoint omits a block from the middle of an
actions(fromActionState: …) response, every remaining block still self-validates, o1js
raises nothing, and Reducer.getActions folds straight across the gap to a wrong action
state. The resulting actionState precondition can never match the chain, so the
transaction is built, proved and submitted, and then fails on-chain with
Account_action_state_precondition_unsatisfied.

The response already carries everything needed to detect this. It is simply not compared.

This is a validation gap in o1js, not the cause of the fault. It is what makes an archive
fault silent instead of loud, for every zkApp and on any endpoint.

Where

src/lib/mina/v1/fetch.ts#L950 — inside the forEach, the fold is re-seeded from each block's own actionStateTwo (L970):

fetchedActions.forEach((actionBlock) => {
  let { actionData } = actionBlock;
  let latestActionState = Field(actionBlock.actionState.actionStateTwo);   // L970 — reseeded every block
  let actionState = actionBlock.actionState.actionStateOne;
  ...
  const finalActionState = latestActionState.toString();
  const expectedActionState = actionState;
  if (finalActionState !== expectedActionState) {                          // L1027 — within this block only
    throw new Error(`Failed to derive correct actions hash for ${publicKey}. …`);
  }
});

actionStateTwo appears exactly once in the whole file — that reseed. It is never used
in a comparison. So the existing check answers "do this block's actions hash to this block's
own result?" and never "does this block continue the previous one?".

Confirmed identical in 2.0.0, 3.0.0-mesa-native.0, and current main.

Why it matters — a real incident

A Mina archive GraphQL deployment returned an actions(fromActionState: X) list with one
real, canonical, on-chain-confirmed block missing from the middle. The cause was server-side
(the filter compared the interning key zkapp_field.id instead of a position on the chain,
so it dropped an entry whenever an archive had been filled out of chain order — a bulk
import, a hard-fork migration, a bootstrap). It is being fixed there:
o1-labs/Archive-Node-API#209.

On that archive, 1 003 of 2 778 zkApp accounts had at least one affected checkpoint.

From the client's side, the whole event was invisible. fetchActions returned successfully.
createActionsList threw nothing. The contract folded 31 actions where the chain had 32 and
produced an actionState that had never been valid. The first sign of trouble was a failed
transaction on-chain, with nothing in the client to point at.

With a link check, fetchActions would have thrown at the moment the bad response arrived,
naming the two block heights.

Reproduce

Take any valid response and delete one entry from the middle:

const actions = [...];          // a real actions(fromActionState: X) response, N >= 3 blocks
actions.splice(1, 1);           // drop one block from the middle
createActionsList({ publicKey, actionStates: { fromActionState: X } }, actions);
// -> returns normally today; the resulting hashes fold to a state that is not on chain

Proposed fix

Assert the link between consecutive blocks before the per-block loop:

for (let i = 0; i + 1 < fetchedActions.length; i++) {
  const current = fetchedActions[i].actionState.actionStateOne;
  const next = fetchedActions[i + 1].actionState.actionStateTwo;
  if (current !== next) {
    throw new Error(
      `Actions are not contiguous for ${publicKey}: the action state after block ` +
        `${fetchedActions[i].blockInfo?.height} is ${current}, but the next block ` +
        `${fetchedActions[i + 1].blockInfo?.height} starts from ${next}. ` +
        `The archive endpoint returned an incomplete action list. ` +
        `Please try a different Archive Node API endpoint.`
    );
  }
}

It costs one string comparison per block and needs no extra data. It catches dropped
entries, duplicated entries, and entries returned from before the requested checkpoint.

Two optional additions, if you want them in the same change:

  • After the loop, check that the first kept block's actionStateTwo equals the requested
    fromActionState. That catches a response whose head is wrong, not only its middle.
  • The message should point at the endpoint, as the existing one does — in the incident above
    the fault really was the endpoint.

Happy to open the PR if you would like it.

Related

Reducer.getActions does assert in-circuit when the caller passes endActionState
(reducer.ts, actions.hash.assertEquals(config.endActionState)), so a zkApp that supplies it fails at
proving time rather than submitting a doomed transaction. That is a way to notice the fault,
not protection against it — and most reducer code has no endActionState to supply.

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