← Back to Stage 3
Stage 3#301Architecture · Senior~16 min read

Operation State Machine for Tokenized Transfer UI

Operation State Architecture · State Machines

Model draft, signing, submitted, locally pending, chain finalized, reconciliation pending, reconciled, retryable failure, and terminal failure.

Prompt

Design and implement the frontend state model for a tokenized transfer screen.

The user enters an amount, confirms in a wallet or approval dialog, and then waits while several systems catch up: local UI, wallet/provider, backend API, chain observation, and business reconciliation. The UI must survive reloads, prevent unsafe repeated submits, and avoid showing certainty before the system actually has it.

💡
Tip

Use this page as an interview rep: attempt the drill first, speak through the system-design prompt, then compare against the model answer.

Foundation

Grounding: the truths are owned by different systems

Before coding, name the owners of truth:

OwnerOwnsExample
FormUser intent before submitamount, recipient, memo
WalletUser approval and provider transactionsignature rejected, tx hash returned
Local storageContinuity for this browserclient operation ID, tx hash, last known status
BackendCanonical business operationtransfer ID, idempotency result, reconciliation state
Chain observerTransaction observationcontained, finalized, failed on-chain
UIDerived explanationbanner, disabled controls, next action

The frontend should own continuity and explanation. It should not pretend to own the canonical business record.

Good interview language:

  • "Wallet rejected" means no transfer was submitted.
  • "Submitted" means the user intent left the browser, not that money movement is complete.
  • "Finalized" means chain observation is complete, not necessarily business reconciliation.
  • "Reconciled" is the business-complete state the UI can present as final.
Coding drill

Implement transfer state transitions

Target: 20mNot run

Fill in transition() and toOperationView() so the harness can prove the model distinguishes wallet, backend, chain, and reconciliation states.

type DraftInput = {
  recipient: string;
  amountMinor: bigint;
};

type OperationRecord =
  | { tag: 'draft'; input: DraftInput }
  | { tag: 'signing'; clientOperationId: string; input: DraftInput }
  | { tag: 'wallet-rejected'; input: DraftInput; message: string }
  | {
      tag: 'submitted';
      clientOperationId: string;
      input: DraftInput;
      txHash?: string;
    }
  | {
      tag: 'backend-registered';
      clientOperationId: string;
      transferId: string;
      input: DraftInput;
      txHash?: string;
    }
  | {
      tag: 'chain-finalized';
      clientOperationId: string;
      transferId: string;
      input: DraftInput;
      txHash: string;
    }
  | {
      tag: 'reconciled';
      clientOperationId: string;
      transferId: string;
      input: DraftInput;
      txHash: string;
    }
  | {
      tag: 'failed-retryable';
      clientOperationId: string;
      input: DraftInput;
      reason: 'api-timeout' | 'observer-lag';
      txHash?: string;
      transferId?: string;
    }
  | {
      tag: 'failed-terminal';
      clientOperationId: string;
      input: DraftInput;
      reason: 'chain-reverted' | 'compliance-blocked';
      txHash?: string;
      transferId?: string;
    };

type OperationEvent =
  | { type: 'start-signing'; clientOperationId: string }
  | { type: 'wallet-rejected'; message: string }
  | { type: 'wallet-submitted'; txHash?: string }
  | { type: 'backend-registered'; transferId: string; txHash?: string }
  | { type: 'chain-finalized'; txHash: string }
  | { type: 'reconciled' }
  | { type: 'retryable-failure'; reason: 'api-timeout' | 'observer-lag' }
  | { type: 'terminal-failure'; reason: 'chain-reverted' | 'compliance-blocked' };

type OperationView = {
  headline: string;
  canEdit: boolean;
  canSubmit: boolean;
  canRetry: boolean;
  tone: 'neutral' | 'progress' | 'success' | 'warning' | 'danger';
};

function transition(
  state: OperationRecord,
  event: OperationEvent
): OperationRecord {
  // TODO: implement the allowed transitions.
  // Return the current state for events that do not make sense.
  return state;
}

function toOperationView(state: OperationRecord): OperationView {
  // TODO: derive UI behavior from the durable operation state.
  return {
    headline: 'TODO',
    canEdit: true,
    canSubmit: true,
    canRetry: false,
    tone: 'neutral',
  };
}
TypeScript · runnable
System design

Explain the architecture out loud

Timebox yourself to 8 minutes. Answer as if an interviewer asked: "How would you design the frontend state model for this transfer flow?"

Cover these points:

  • What state lives only in the form?
  • What state is persisted locally for reload recovery?
  • What is owned by the backend API?
  • What is observed from the chain or external settlement system?
  • Which UI states disable submit, allow retry, or require support copy?
  • How do you prevent duplicate transfers?
Self-grade

Self-grade your answer

Strong answer:

  • Names each source of truth before naming components.
  • Uses an explicit state machine or reducer, not scattered booleans.
  • Treats wallet rejection as editable/cancelled, not failed transfer.
  • Persists submitted-but-not-yet-canonical operations with a client operation ID.
  • Keeps chain-finalized and business-reconciled separate.
  • Uses idempotency keys and backend semantics, not only disabled buttons.
  • Mentions tests for reload recovery, duplicate click, delayed backend, and retryable failure.

Weak answer:

  • Uses one status string from the backend for everything.
  • Says "optimistic update" for money movement without qualification.
  • Does not explain what happens after refresh.
  • Does not distinguish user cancellation from terminal failure.

Model Answer

Explicit operation record + derived view state

TimeO(1) per transition
SpaceO(1)
Recommended

Use a discriminated union for durable operation state. Components should render from a derived OperationView, while persistence and API reconciliation operate on the durable OperationRecord.

function transition(
  state: OperationRecord,
  event: OperationEvent
): OperationRecord {
  switch (event.type) {
    case 'start-signing':
      if (state.tag !== 'draft' && state.tag !== 'wallet-rejected') return state;
      return {
        tag: 'signing',
        clientOperationId: event.clientOperationId,
        input: state.input,
      };

    case 'wallet-rejected':
      if (state.tag !== 'signing') return state;
      return { tag: 'wallet-rejected', input: state.input, message: event.message };

    case 'wallet-submitted':
      if (state.tag !== 'signing') return state;
      return {
        tag: 'submitted',
        clientOperationId: state.clientOperationId,
        input: state.input,
        txHash: event.txHash,
      };

    case 'backend-registered':
      if (state.tag !== 'submitted' && state.tag !== 'failed-retryable') return state;
      return {
        tag: 'backend-registered',
        clientOperationId: state.clientOperationId,
        transferId: event.transferId,
        input: state.input,
        txHash: event.txHash ?? state.txHash,
      };

    case 'chain-finalized':
      if (state.tag !== 'backend-registered') return state;
      return {
        tag: 'chain-finalized',
        clientOperationId: state.clientOperationId,
        transferId: state.transferId,
        input: state.input,
        txHash: event.txHash,
      };

    case 'reconciled':
      if (state.tag !== 'chain-finalized') return state;
      return {
        tag: 'reconciled',
        clientOperationId: state.clientOperationId,
        transferId: state.transferId,
        input: state.input,
        txHash: state.txHash,
      };

    case 'retryable-failure':
      if (state.tag === 'draft' || state.tag === 'wallet-rejected') return state;
      return {
        tag: 'failed-retryable',
        clientOperationId: state.clientOperationId,
        input: state.input,
        reason: event.reason,
        txHash: 'txHash' in state ? state.txHash : undefined,
        transferId: 'transferId' in state ? state.transferId : undefined,
      };

    case 'terminal-failure':
      if (state.tag === 'draft' || state.tag === 'wallet-rejected') return state;
      return {
        tag: 'failed-terminal',
        clientOperationId: state.clientOperationId,
        input: state.input,
        reason: event.reason,
        txHash: 'txHash' in state ? state.txHash : undefined,
        transferId: 'transferId' in state ? state.transferId : undefined,
      };
  }
}

Trace

StepDurable stateUI behavior
1draftform editable, submit enabled
2signingform locked, approval prompt copy visible
3submittedlocal pending record saved, duplicate submit blocked
4backend-registeredsupport/reference ID visible
5chain-finalizedtransaction finality visible, business completion not claimed
6reconciledshow completion and clear local pending state
Review

Interview recall

Before moving on, answer these without looking:

  • Why is wallet rejection not a failed transfer?
  • What does the frontend own after wallet submission but before backend registration?
  • Why is a disabled submit button insufficient for duplicate-transfer prevention?
  • What user copy changes between finalized and reconciled?
  • Which tests would you write first?

Core line to remember:

The frontend owns continuity and explanation. The backend owns canonical business records. The chain or settlement observer owns finality. The UI should derive from those truths instead of pretending one status field knows everything.