Skip to main content

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}