palinurus_core/durable_nonce.rs
1//! Durable transaction nonces — the blockhash-expiry fix.
2//!
3//! An agent builds a tx → it drops into an approval queue → 5 min later the
4//! blockhash is dead and the tx can't land. A **durable nonce** replaces the
5//! `recent_blockhash` with a value stored in an on-chain nonce account, so the
6//! tx never expires. Each submit advances the stored nonce, which is how a
7//! replayed signed durable-nonce tx is rejected.
8//!
9//! ## Usage (T1 unsigned)
10//!
11//! The plugin reads the nonce account from chain (`rpc.get_account_info`),
12//! parses it with [`parse_nonce_account`] to extract the stored `durable_nonce`
13//! and `authority`, then [`build_with_durable_nonce`] prepends an `Advance`
14//! instruction and uses the stored nonce as `recent_blockhash`. A human / Squads
15//! multisig (the authority) signs and submits. The tx lives until they sign.
16//!
17//! ## Consensus-critical — oracle-verified
18//!
19//! The `Advance` / `Authorize` instruction bytes and the `NonceAccount`
20//! (`Versions`) byte layout are verified byte-for-byte against
21//! `solana_program::system_instruction` and `solana_program::nonce::state` in
22//! `tests/durable_nonce.rs` (same rigor as PDA + versioned_tx).
23//!
24//! ## Nonce account layout (initialized, `Current` version — 80 bytes)
25//!
26//! `[u32 LE version=1 (Current)] [u32 LE state=1 (Initialized)] [32B authority]
27//! [32B durable_nonce] [u64 LE lamports_per_signature]`
28//!
29//! Enum variant tags are `u32 LE` (confirmed by `State::size() == 80`:
30//! 4 + 4 + 32 + 32 + 8). `Legacy` (version=0) durable nonces are invalid for
31//! versioned transactions; the parser still returns them so the plugin can
32//! reject with a clear error.
33
34use crate::base58::Pubkey;
35use crate::versioned_tx::{build_unsigned, AccountMeta, Blockhash, Instruction, VersionedTransaction};
36
37/// A durable nonce value — the 32-byte Hash stored in a nonce account, used as
38/// the `recent_blockhash` of a durable-nonce transaction.
39pub type DurableNonce = Blockhash;
40
41/// Nonce account version (the `Versions` enum tag).
42#[derive(Clone, Copy, PartialEq, Eq, Debug)]
43pub enum NonceVersion {
44 Legacy,
45 Current,
46}
47
48/// Parsed nonce-account state.
49#[derive(Clone, PartialEq, Eq, Debug)]
50pub enum NonceState {
51 Uninitialized,
52 Initialized(NonceData),
53}
54
55/// The initialized nonce-account data (authority + stored nonce + fee).
56#[derive(Clone, PartialEq, Eq, Debug)]
57pub struct NonceData {
58 /// The account that must sign transactions using this nonce (and the
59 /// `Advance` instruction).
60 pub authority: Pubkey,
61 /// The durable nonce — use as `recent_blockhash`.
62 pub durable_nonce: DurableNonce,
63 /// Fee per signature (from the `FeeCalculator` stored alongside the nonce).
64 pub lamports_per_signature: u64,
65}
66
67/// A parsed nonce account.
68#[derive(Clone, PartialEq, Eq, Debug)]
69pub struct NonceAccount {
70 pub version: NonceVersion,
71 pub state: NonceState,
72}
73
74#[derive(Clone, PartialEq, Eq, Debug)]
75pub enum NonceError {
76 /// The account data is too short to contain the version + state tags (or
77 /// too short for an Initialized account's 80 bytes).
78 InvalidLength,
79 /// Unknown `Versions` variant tag (not 0=Legacy or 1=Current).
80 UnsupportedVersion(u32),
81 /// Unknown `State` variant tag (not 0=Uninitialized or 1=Initialized).
82 BadStateTag(u32),
83}
84
85/// The `RecentBlockhashes` sysvar account — required (readonly, non-signer) by
86/// the `Advance` instruction. Bytes from `@solana/web3.js` (base58
87/// `SysvarRecentB1ockHashes11111111111111111111`).
88pub const RECENT_BLOCKHASHES_ID: Pubkey = Pubkey::from_bytes([
89 0x06, 0xa7, 0xd5, 0x17, 0x19, 0x2c, 0x56, 0x8e, 0xe0, 0x8a, 0x84, 0x5f, 0x73, 0xd2, 0x97, 0x88,
90 0xcf, 0x03, 0x5c, 0x31, 0x45, 0xb2, 0x1a, 0xb3, 0x44, 0xd8, 0x06, 0x2e, 0xa9, 0x40, 0x00, 0x00,
91]);
92
93/// `SystemInstruction::AdvanceNonceAccount` = variant index 4, bincode-serialized
94/// as a `u32 LE` tag with no payload → `[0x04, 0x00, 0x00, 0x00]`.
95const ADVANCE_NONCE_ACCOUNT_IX_DATA: [u8; 4] = [0x04, 0x00, 0x00, 0x00];
96
97/// `SystemInstruction::AuthorizeNonceAccount(Pubkey)` = variant index 7, bincode
98/// as `u32 LE` tag + 32-byte pubkey.
99const AUTHORIZE_NONCE_ACCOUNT_DISCRIMINATOR: [u8; 4] = [0x07, 0x00, 0x00, 0x00];
100
101/// Build the System program `AdvanceNonceAccount` instruction.
102///
103/// Accounts (in order): `[nonce (writable, non-signer), RecentBlockhashes sysvar
104/// (readonly, non-signer), authority (readonly, signer)]`. Data =
105/// `[0x04,0x00,0x00,0x00]`. Verified byte-for-byte against
106/// `solana_program::system_instruction::advance_nonce_account`.
107pub fn nonce_advance_ix(nonce: Pubkey, authority: Pubkey) -> Instruction {
108 Instruction {
109 program_id: Pubkey::SYSTEM,
110 accounts: vec![
111 AccountMeta::writable(nonce),
112 AccountMeta::readonly(RECENT_BLOCKHASHES_ID),
113 AccountMeta::signer_readonly(authority),
114 ],
115 data: ADVANCE_NONCE_ACCOUNT_IX_DATA.to_vec(),
116 }
117}
118
119/// Build the System program `AuthorizeNonceAccount(new_authority)` instruction.
120///
121/// Accounts: `[nonce (writable, non-signer), authority (readonly, signer)]`.
122/// Data = `[0x07,0x00,0x00,0x00]` + 32-byte `new_authority`. Verified byte-for-byte
123/// against `solana_program::system_instruction::authorize_nonce_account`.
124pub fn nonce_authorize_ix(nonce: Pubkey, authority: Pubkey, new_authority: Pubkey) -> Instruction {
125 let mut data = AUTHORIZE_NONCE_ACCOUNT_DISCRIMINATOR.to_vec();
126 data.extend_from_slice(new_authority.as_bytes());
127 Instruction {
128 program_id: Pubkey::SYSTEM,
129 accounts: vec![
130 AccountMeta::writable(nonce),
131 AccountMeta::signer_readonly(authority),
132 ],
133 data,
134 }
135}
136
137/// Parse a nonce account's data bytes into a [`NonceAccount`].
138///
139/// Matches `bincode::deserialize::<solana_nonce::state::Versions>`: `u32 LE`
140/// version tag (0=Legacy, 1=Current) + `u32 LE` state tag (0=Uninitialized,
141/// 1=Initialized) + (if Initialized) 32B authority + 32B durable_nonce +
142/// `u64 LE` lamports_per_signature.
143pub fn parse_nonce_account(data: &[u8]) -> Result<NonceAccount, NonceError> {
144 if data.len() < 8 {
145 return Err(NonceError::InvalidLength);
146 }
147 let version_tag = u32::from_le_bytes(data[0..4].try_into().unwrap());
148 let version = match version_tag {
149 0 => NonceVersion::Legacy,
150 1 => NonceVersion::Current,
151 other => return Err(NonceError::UnsupportedVersion(other)),
152 };
153 let state_tag = u32::from_le_bytes(data[4..8].try_into().unwrap());
154 let state = match state_tag {
155 0 => NonceState::Uninitialized,
156 1 => {
157 if data.len() < 80 {
158 return Err(NonceError::InvalidLength);
159 }
160 let authority = Pubkey::from_bytes(data[8..40].try_into().unwrap());
161 let durable_nonce = data[40..72].try_into().unwrap();
162 let lamports_per_signature = u64::from_le_bytes(data[72..80].try_into().unwrap());
163 NonceState::Initialized(NonceData {
164 authority,
165 durable_nonce,
166 lamports_per_signature,
167 })
168 }
169 other => return Err(NonceError::BadStateTag(other)),
170 };
171 Ok(NonceAccount { version, state })
172}
173
174/// Build an unsigned durable-nonce versioned transaction (T1): `Advance` ix
175/// first, then `user_ixs`, with `nonce` (the stored `DurableNonce`) as
176/// `recent_blockhash`. The `authority` + `payer` sign later (human / Squads
177/// multisig). The tx does not expire while it waits.
178///
179/// Verified byte-for-byte against
180/// `solana_program::message::v0::Message::try_compile([advance, ..user], &[], Hash(nonce))`.
181pub fn build_with_durable_nonce(
182 user_ixs: &[Instruction],
183 payer: Pubkey,
184 nonce_account: Pubkey,
185 nonce: DurableNonce,
186 authority: Pubkey,
187) -> VersionedTransaction {
188 let advance = nonce_advance_ix(nonce_account, authority);
189 let mut all = Vec::with_capacity(user_ixs.len() + 1);
190 all.push(advance);
191 all.extend(user_ixs.iter().cloned());
192 build_unsigned(&all, payer, nonce)
193}