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.
Use this page as an interview rep: attempt the drill first, speak through the system-design prompt, then compare against the model answer.
Grounding: the truths are owned by different systems
Before coding, name the owners of truth:
| Owner | Owns | Example |
|---|---|---|
| Form | User intent before submit | amount, recipient, memo |
| Wallet | User approval and provider transaction | signature rejected, tx hash returned |
| Local storage | Continuity for this browser | client operation ID, tx hash, last known status |
| Backend | Canonical business operation | transfer ID, idempotency result, reconciliation state |
| Chain observer | Transaction observation | contained, finalized, failed on-chain |
| UI | Derived explanation | banner, 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.
Implement transfer state transitions
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',
};
}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 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
statusstring 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
O(1) per transitionO(1)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,
};
}
}function toOperationView(state: OperationRecord): OperationView {
switch (state.tag) {
case 'draft':
return {
headline: 'Ready to review',
canEdit: true,
canSubmit: true,
canRetry: false,
tone: 'neutral',
};
case 'signing':
return {
headline: 'Waiting for approval',
canEdit: false,
canSubmit: false,
canRetry: false,
tone: 'progress',
};
case 'wallet-rejected':
return {
headline: 'Approval cancelled',
canEdit: true,
canSubmit: true,
canRetry: false,
tone: 'warning',
};
case 'submitted':
case 'backend-registered':
case 'chain-finalized':
return {
headline: 'Transfer in progress',
canEdit: false,
canSubmit: false,
canRetry: false,
tone: 'progress',
};
case 'reconciled':
return {
headline: 'Transfer complete',
canEdit: false,
canSubmit: false,
canRetry: false,
tone: 'success',
};
case 'failed-retryable':
return {
headline: 'Needs attention',
canEdit: false,
canSubmit: false,
canRetry: true,
tone: 'warning',
};
case 'failed-terminal':
return {
headline: 'Transfer failed',
canEdit: false,
canSubmit: false,
canRetry: false,
tone: 'danger',
};
}
}Trace
| Step | Durable state | UI behavior |
|---|---|---|
| 1 | draft | form editable, submit enabled |
| 2 | signing | form locked, approval prompt copy visible |
| 3 | submitted | local pending record saved, duplicate submit blocked |
| 4 | backend-registered | support/reference ID visible |
| 5 | chain-finalized | transaction finality visible, business completion not claimed |
| 6 | reconciled | show completion and clear local pending state |
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.