You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Same account model, PDA layout, and instruction set — this only changes what ExecutionStage/TwoStageProgress` carry and how restart recovery reads it.
Motivation
MIMD-0025's central claim is: no stage is executed twice, guaranteed by
writing SetIntentExecutionStage(signature) to AccountsDBbefore the L1
transaction is submitted. On restart, an intent found in status = Executing(signature) queries L1 for that signature and decides:
getSignatureStatuses cannot distinguish two very different situations:
The validator crashed after writing SetIntentExecutionStage but before the L1 send call ever went out — the signature genuinely was
never transmitted.
The transaction was sent and is simply not yet visible — RPC
propagation lag, or it's sitting in a leader's pending queue.
Both read back as "not found." MIMD-0025's original recovery pseudocode
treated "not found" as "safe to resend" (_ => reschedule_full). That's
correct for case 1 and wrong for case 2: if the original transaction is
still in flight and we build + send a second one for the same intent, both
can land — double commit, double finalize, or a DLP-level nonce conflict.
So the write-before-send ordering didn't eliminate the crash-window race
described in MIMD-0025's own "Crash 4→5" case — it just moved the failure
mode from lost intent (acceptable, retried safely) to double-executed
intent (state corruption), which is worse. This is the same race the
original design set out to prevent; the fix was incomplete because "was it
sent" isn't a question getSignatureStatuses alone can answer.
Solution: PendingTransaction
Give up trying to answer "was it sent." Ask "can it still land" instead —
the blockhash the transaction was built with answers that for us, and it's
information we already have at send time for free.
pubstructPendingTransaction{pubsignature:Signature,pubblockhash:Hash,}pubenumExecutionStage{SingleStage(PendingTransaction),TwoStage(TwoStageProgress),}pubenumTwoStageProgress{Committing(PendingTransaction),Finalizing{commit:Signature,// already resolved — bare Signaturefinalize:PendingTransaction,// leading edge — still ambiguous},}
Only the leading edge of execution is ambiguous — Finalizing.commit is
already known-succeeded by the time we advance to it, so it stays a bare Signature.
Classification
enumPendingSignatureStatus{Succeeded,// found on-chain, okPending,// not found, blockhash still valid — may still landRejected,// found & failed, OR not found & blockhash expired — dead}
Found on-chain (success or failure) → definitive, act on it.
Not found, blockhash still valid → Pending. This covers case 1 and
case 2 identically — in both, the only safe action is to wait and
recheck. We don't need to tell them apart; the blockhash's own
~60-90s validity window is what eventually resolves the ambiguity, not
our classification of it.
Not found, blockhash expired → Rejected. Solana rejects transactions
built on an expired blockhash outright, so this is guaranteed dead
regardless of which case it was — safe to build and send a fresh
transaction.
Check ordering matters
Blockhash validity is checked before signature status, not after.
Validity is monotonic — once expired, always expired — so a slightly-stale
"expired" reading is still safe to act on later. But "signature not found"
is a snapshot that can flip to "found" at any subsequent instant. If we
checked "not found" first and blockhash-expiry second, the transaction
could land in the gap between the two RPC calls, and we'd have already
concluded Rejected and be racing a resend against a transaction that's
about to succeed. Checking blockhash validity first closes that window.
resolve_pending_signature wraps this in a sleep-and-recheck loop until it
resolves definitively (naturally bounded by the blockhash's own expiry),
collapsing Pending away into a plain bool for callers — restart
recovery in particular — that aren't already sitting inside a retry loop
and shouldn't act on an intermediate Pending reading.
Where this plugs in
The same protection covers both cross-restart recovery and any in-process
retry of the execution loop, because both drive through the same state:
loop{ifletSome(ref pending) = state.pending_transaction{matchcheck_pending_signature(intent_client, pending).await? {Succeeded => breakOk(Ok(pending.signature)),Pending => {sleep(POLL_INTERVAL).await;continue}Rejected => *state.pending_transaction = None,}}// ... prepare transaction ...// Record in outbox BEFORE sending (same ordering MIMD-0025 established)let pending = PendingTransaction{ signature, blockhash };
outbox_client.set_intent_execution_stage(intent_id,make_stage(pending)).await?;*state.pending_transaction = Some(pending);// Send — if we crash here, `pending_transaction` is already recorded// in the outbox with the blockhash needed to resolve it laterlet result = intent_client.send_signed_tx_with_retries(...).await;*state.pending_transaction = None;// ...}
The window during which a signature is "pending" — in the outbox account
and in the in-memory ExecutionState — brackets exactly the span where its
fate is genuinely unknown: set right after the outbox write, cleared as
soon as the send call returns (success or definitive failure).
Impact on MIMD-0025 restart classification
The recovery pseudocode's get_signature_status(sig) branches become resolve_pending_signature(&pending):
Tradeoff: restart recovery for an intent caught mid-send is no longer a
single RPC round-trip — it can now block for up to the blockhash's validity
window (~60-90s) while resolve_pending_signature polls. This is an
explicit, bounded latency cost accepted in exchange for never
double-executing an intent.
Delta from MIMD-0025
Component
Change
magicblock-magic-program-api/src/outbox.rs
Added PendingTransaction { signature, blockhash }. ExecutionStage::SingleStage and TwoStageProgress::Committing/Finalizing.finalize now hold PendingTransaction instead of bare Signature. Finalizing.commit stays a bare Signature (already resolved when reached). Added ExecutionStage::apply_stage_transition / TwoStageProgress::apply_stage_transition, implementing the same transition table MIMD-0025 specified for SetIntentExecutionStage (no TwoStage→SingleStage downgrade, no Accepted→Finalizing skip, no Finalizing→Committing downgrade, in-place signature replacement on retry within the same stage).
New: PendingSignatureStatus, check_pending_signature, resolve_pending_signature, and the shared stage_execution_loop that single-stage and both two-stage phases drive through, carrying pending_transaction: &mut Option<PendingTransaction> in ExecutionState.
No change to the MagicIntentAccount PDA layout, seeds, discovery mechanism
(getProgramAccounts), or instruction set beyond the payload type carried
by ExecutionStage.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
-
Pending Transaction Tracking for Intent Execution Durability
Same account model, PDA layout, and instruction set — this only changes what ExecutionStage
/TwoStageProgress` carry and how restart recovery reads it.Motivation
MIMD-0025's central claim is: no stage is executed twice, guaranteed by
writing
SetIntentExecutionStage(signature)toAccountsDBbefore the L1transaction is submitted. On restart, an intent found in
status = Executing(signature)queries L1 for that signature and decides:That
_arm is where the guarantee quietly breaks.Problem
getSignatureStatusescannot distinguish two very different situations:SetIntentExecutionStagebutbefore the L1 send call ever went out — the signature genuinely was
never transmitted.
propagation lag, or it's sitting in a leader's pending queue.
Both read back as "not found." MIMD-0025's original recovery pseudocode
treated "not found" as "safe to resend" (
_ => reschedule_full). That'scorrect for case 1 and wrong for case 2: if the original transaction is
still in flight and we build + send a second one for the same intent, both
can land — double commit, double finalize, or a DLP-level nonce conflict.
So the write-before-send ordering didn't eliminate the crash-window race
described in MIMD-0025's own "Crash 4→5" case — it just moved the failure
mode from lost intent (acceptable, retried safely) to double-executed
intent (state corruption), which is worse. This is the same race the
original design set out to prevent; the fix was incomplete because "was it
sent" isn't a question
getSignatureStatusesalone can answer.Solution: PendingTransaction
Give up trying to answer "was it sent." Ask "can it still land" instead —
the blockhash the transaction was built with answers that for us, and it's
information we already have at send time for free.
Only the leading edge of execution is ambiguous —
Finalizing.commitisalready known-succeeded by the time we advance to it, so it stays a bare
Signature.Classification
Pending. This covers case 1 andcase 2 identically — in both, the only safe action is to wait and
recheck. We don't need to tell them apart; the blockhash's own
~60-90s validity window is what eventually resolves the ambiguity, not
our classification of it.
Rejected. Solana rejects transactionsbuilt on an expired blockhash outright, so this is guaranteed dead
regardless of which case it was — safe to build and send a fresh
transaction.
Check ordering matters
Blockhash validity is checked before signature status, not after.
Validity is monotonic — once expired, always expired — so a slightly-stale
"expired" reading is still safe to act on later. But "signature not found"
is a snapshot that can flip to "found" at any subsequent instant. If we
checked "not found" first and blockhash-expiry second, the transaction
could land in the gap between the two RPC calls, and we'd have already
concluded
Rejectedand be racing a resend against a transaction that'sabout to succeed. Checking blockhash validity first closes that window.
resolve_pending_signaturewraps this in a sleep-and-recheck loop until itresolves definitively (naturally bounded by the blockhash's own expiry),
collapsing
Pendingaway into a plainboolfor callers — restartrecovery in particular — that aren't already sitting inside a retry loop
and shouldn't act on an intermediate
Pendingreading.Where this plugs in
The same protection covers both cross-restart recovery and any in-process
retry of the execution loop, because both drive through the same state:
The window during which a signature is "pending" — in the outbox account
and in the in-memory
ExecutionState— brackets exactly the span where itsfate is genuinely unknown: set right after the outbox write, cleared as
soon as the send call returns (success or definitive failure).
Impact on MIMD-0025 restart classification
The recovery pseudocode's
get_signature_status(sig)branches becomeresolve_pending_signature(&pending):Tradeoff: restart recovery for an intent caught mid-send is no longer a
single RPC round-trip — it can now block for up to the blockhash's validity
window (~60-90s) while
resolve_pending_signaturepolls. This is anexplicit, bounded latency cost accepted in exchange for never
double-executing an intent.
Delta from MIMD-0025
magicblock-magic-program-api/src/outbox.rsPendingTransaction { signature, blockhash }.ExecutionStage::SingleStageandTwoStageProgress::Committing/Finalizing.finalizenow holdPendingTransactioninstead of bareSignature.Finalizing.commitstays a bareSignature(already resolved when reached). AddedExecutionStage::apply_stage_transition/TwoStageProgress::apply_stage_transition, implementing the same transition table MIMD-0025 specified forSetIntentExecutionStage(noTwoStage→SingleStagedowngrade, noAccepted→Finalizingskip, noFinalizing→Committingdowngrade, in-place signature replacement on retry within the same stage).magicblock-committor-service/src/intent_executor/strategy_executor/utils.rsPendingSignatureStatus,check_pending_signature,resolve_pending_signature, and the sharedstage_execution_loopthat single-stage and both two-stage phases drive through, carryingpending_transaction: &mut Option<PendingTransaction>inExecutionState.No change to the
MagicIntentAccountPDA layout, seeds, discovery mechanism(
getProgramAccounts), or instruction set beyond the payload type carriedby
ExecutionStage.Beta Was this translation helpful? Give feedback.
All reactions