From 5edd18ca9019563bc96a771c01fcb6fdd5e2893c Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Fri, 10 Apr 2026 20:12:43 +0200 Subject: [PATCH 1/9] Base interfaces & data structures --- Cargo.lock | 402 +++++++++++++++++++++++++++++++++++ interface/Cargo.toml | 21 +- interface/src/instruction.rs | 77 +++++++ interface/src/lib.rs | 9 + interface/src/message.rs | 197 +++++++++++++++++ interface/src/pda.rs | 57 +++++ interface/src/state.rs | 48 +++++ program/Cargo.toml | 3 +- scripts/solana.dic | 2 + 9 files changed, 807 insertions(+), 9 deletions(-) create mode 100644 interface/src/instruction.rs create mode 100644 interface/src/message.rs create mode 100644 interface/src/pda.rs create mode 100644 interface/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 92736cd..d4d401a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,412 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "five8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f76610e969fa1784327ded240f1e28a3fd9520c9cec93b636fcf62dd37f772" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0f1728185f277989ca573a402716ae0beaaea3f76a8ff87ef9dd8fb19436c5" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059c31d7d36c43fe39d89e55711858b4da8be7eb6dabac23c7289b1a19489406" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "solana-address" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f08236dacd0e6dc8234becef58e27c8567856644ef509d7e97957d55a91dc72" +dependencies = [ + "curve25519-dalek", + "five8", + "five8_const", + "sha2-const-stable", + "solana-define-syscall 5.0.0", + "solana-program-error", + "solana-sha256-hasher", + "wincode", +] + +[[package]] +name = "solana-define-syscall" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" + +[[package]] +name = "solana-define-syscall" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03aacdd7a61e2109887a7a7f046caebafce97ddf1150f33722eeac04f9039c73" + +[[package]] +name = "solana-hash" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b113239362cee7093bfb250467138f079a2a03673181dc15bff6ccd677912d" + +[[package]] +name = "solana-program-error" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" + +[[package]] +name = "solana-sanitize" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" + +[[package]] +name = "solana-sha256-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" +dependencies = [ + "sha2", + "solana-define-syscall 4.0.1", + "solana-hash", +] + +[[package]] +name = "solana-signature" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a73c6e97cc2108be0adf6a6ea326434f8398df9d7eed81da2a4548b69e971c" +dependencies = [ + "five8", + "solana-sanitize", +] + +[[package]] +name = "solana-zero-copy" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f52dd8f733a13f6a18e55de83cf97c4c3f5fdf27ea3830bcff0b35313efcc2" +dependencies = [ + "wincode", +] + [[package]] name = "spl-nonce-interface" version = "0.1.0" +dependencies = [ + "solana-address", + "solana-sha256-hasher", + "solana-signature", + "solana-zero-copy", + "wincode", +] [[package]] name = "spl-nonce-program" version = "0.1.0" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wincode" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "657690780ce23e6f66576a782ffd88eb353512381817029cc1d7a99154bb6d1f" +dependencies = [ + "pastey", + "proc-macro2", + "quote", + "thiserror", + "wincode-derive", +] + +[[package]] +name = "wincode-derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca057fc9a13dd19cdb64ef558635d43c42667c0afa1ae7915ea1fa66993fd1a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 0327d4a..52f7aa8 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -1,20 +1,27 @@ [package] name = "spl-nonce-interface" version = "0.1.0" -description = "TBD" -authors = {workspace = true} -repository = {workspace = true} -homepage = {workspace = true} -license = {workspace = true} -edition = {workspace = true} +description = "Interface for the SPL Nonce program" +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +edition = { workspace = true } [lib] crate-type = ["rlib"] [package.metadata.solana] -program-id = "2iZvRhbVukqhBXdKTpjmY5w2omXQbziFq1r5WkxSJKFD" +program-id = "nonce34S3Viw97xQwWGpRWEGufiSpfVEAiFe7Lefv7y" [lints] workspace = true [dependencies] +# TODO: Need to update `solana-zero-copy` to use latest wincode before +# we can use the latest `solana-address` & wincode versions +solana-address = { version = "=2.5.0", features = ["curve25519", "syscalls", "decode", "wincode"] } +solana-sha256-hasher = { version = "3.1.0", default-features = false, features = ["sha2"] } +solana-signature = { version = "3.4.0", default-features = false } +solana-zero-copy = { version = "1.0.0", features = ["wincode"] } +wincode = { version = "0.4.9", features = ["derive"] } diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs new file mode 100644 index 0000000..184cf94 --- /dev/null +++ b/interface/src/instruction.rs @@ -0,0 +1,77 @@ +/// Instructions supported by the SPL Nonce program. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NonceInstruction { + /// Creates a new nonce state account for the given authority policy. Anyone + /// may initialize the canonical pre-funded PDA for a given authority policy. + /// + /// On success, the state account is initialized with: + /// - `nonce = 0` + /// - the authority policy that will govern future signed actions + /// + /// The nonce state address is derived from [`NonceStatePda`](crate::pda::NonceStatePda) + /// and must already be pre-funded with enough lamports to be rent-exempt before + /// this instruction runs. During initialization, the program claims that PDA + /// as nonce state and writes the initial state into it. + /// + /// Accounts required: + /// - `[writable]` Nonce state PDA to initialize (pre-funded) + /// - `[]` System program used to allocate and assign the pre-funded PDA + Initialize, + + /// Verifies threshold Ed25519 signatures over a signed message, then + /// performs the action committed in that message. + /// + /// The signed message specifies one of: + /// - `Execute`: run signed CPI instructions + /// - `AdvanceNonce`: increment the nonce, invalidating all previously signed messages + /// - `Close`: close the state account and refund lamports + /// + /// On success, the program: + /// 1. Verifies that enough authority-policy members signed the message + /// 2. Checks the message nonce matches the state and the deadline has not passed + /// 3. Performs the committed action + /// + /// The instruction data format is defined in [`message`](crate::message). + /// + /// Accounts required: + /// - `[writable]` Nonce state PDA + /// - Remaining: accounts from the signed message's account table, in the + /// exact same order. `AdvanceNonce` requires no remaining accounts. + Submit, +} + +impl TryFrom for NonceInstruction { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Initialize), + 1 => Ok(Self::Submit), + _ => Err(()), + } + } +} + +impl From for u8 { + fn from(value: NonceInstruction) -> Self { + value as u8 + } +} + +#[cfg(test)] +mod tests { + use super::NonceInstruction; + + #[test] + fn discriminants_match() { + assert_eq!(u8::from(NonceInstruction::Initialize), 0); + assert_eq!(u8::from(NonceInstruction::Submit), 1); + } + + #[test] + fn try_from_rejects_unknown() { + assert!(NonceInstruction::try_from(2).is_err()); + assert!(NonceInstruction::try_from(255).is_err()); + } +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 665308c..b2e40c0 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -1,2 +1,11 @@ //! Interface for the Nonce program. #![no_std] + +extern crate alloc; + +pub mod instruction; +pub mod message; +pub mod pda; +pub mod state; + +solana_address::declare_id!("nonce34S3Viw97xQwWGpRWEGufiSpfVEAiFe7Lefv7y"); diff --git a/interface/src/message.rs b/interface/src/message.rs new file mode 100644 index 0000000..c6e3851 --- /dev/null +++ b/interface/src/message.rs @@ -0,0 +1,197 @@ +//! Wire-format types for offline-authorized signed messages submitted via `Submit`. +//! +//! Flow: +//! 1. Build a [`SignedMessage`]. +//! 2. Serialize it with `wincode`. +//! 3. Have authority-policy members sign those exact serialized [`SignedMessage`] +//! bytes offline using Ed25519. +//! 4. Construct [`InstructionData`] from the signatures and message. +//! 5. Submit that payload via [`NonceInstruction::Submit`](crate::instruction::NonceInstruction::Submit). +//! +//! The signatures cover only the serialized [`SignedMessage`], not the outer +//! Solana transaction. The transaction is just the transport that carries the +//! signed message to the program. +//! +//! ## Wire layout +//! +//! ```text +//! InstructionData +//! ┌───────────────┬────────────────────────┬────────────────────────┐ +//! │ discriminator │ signatures │ message │ +//! │ u8 │ count:u8 + entries │ SignedMessage │ +//! └───────────────┴────────────────────────┴────────────────────────┘ +//! +//! SignedMessage +//! ┌─────────┬──────────────────┬────────────────────────────────────┐ +//! │ version │ header │ action │ +//! │ u8 │ MessageHeader │ SignedAction │ +//! └─────────┴──────────────────┴────────────────────────────────────┘ +//! +//! MessageHeader +//! ┌──────────────┬──────────────────────────────┐ +//! │ nonce │ deadline │ +//! │ u32 LE │ i64 LE (0 = no expiration) │ +//! └──────────────┴──────────────────────────────┘ +//! ``` +//! +//! ### `SignedAction::Execute` +//! +//! ```text +//! ┌──────────────────────┬──────────────────────────────┐ +//! │ account_table │ instructions │ +//! │ count:u8 + addresses │ count:u8 + CpiInstructions │ +//! └──────────────────────┴──────────────────────────────┘ +//! ``` +//! +//! The `account_table` is the signed list of addresses that CPI instructions +//! reference by index. When the transaction is submitted, the caller must pass +//! those same addresses as remaining accounts on the `Submit` instruction, in +//! the same order: +//! +//! ```text +//! Submit accounts: +//! [0] NonceStatePda (always first) +//! [1] account_table[0] +//! [2] account_table[1] +//! [3] account_table[2] +//! ... +//! ``` +//! +//! The program checks that the submitted accounts match the signed table +//! exactly, preventing account substitution by the submitter. +//! +//! ### `SignedAction::AdvanceNonce` +//! +//! No payload. +//! +//! This action consumes the current nonce and increments it, invalidating all +//! previously signed messages for the account. +//! +//! ### `SignedAction::Close` +//! +//! ```text +//! ┌─────────────────────┐ +//! │ recipient: Address │ +//! └─────────────────────┘ +//! ``` +//! +//! The signed message specifies which address receives the lamports when the +//! nonce state account is closed. + +use { + alloc::vec::Vec, + solana_address::Address, + solana_signature::SIGNATURE_BYTES, + solana_zero_copy::unaligned::{I64, U32}, + wincode::{SchemaRead, SchemaWrite, containers}, +}; + +/// Current signed-message format version. +pub const SIGNED_MESSAGE_VERSION: u8 = 1; + +/// Serialized size of [`MessageHeader`] in bytes. +pub const HEADER_LEN: usize = 12; + +/// Full instruction-data body passed to +/// [`NonceInstruction::Submit`](crate::instruction::NonceInstruction::Submit). +#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] +pub struct InstructionData { + /// Must be `NonceInstruction::Submit` (1). + pub discriminator: u8, + /// Ed25519 signatures over the serialized [`InstructionData::message`]. + #[wincode(with = "containers::Vec")] + pub signatures: Vec, + /// The exact value authority-policy members sign. Contains the nonce, + /// deadline, and action the program verifies and executes. + pub message: SignedMessage, +} + +/// One authority-member approval attached to [`InstructionData`]. +#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] +pub struct SignatureEntry { + /// Index into [`AuthorityPolicy::members`](crate::state::AuthorityPolicy::members). + pub signer_index: u8, + /// Ed25519 signature over the serialized [`SignedMessage`]. + pub signature: [u8; SIGNATURE_BYTES], +} + +/// The message authority-policy members approve offline. +#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] +pub struct SignedMessage { + /// Format version. Must be [`SIGNED_MESSAGE_VERSION`]. + pub version: u8, + /// Replay-protection header containing the expected nonce and optional + /// deadline. + pub header: MessageHeader, + /// The exact action the authority approved. + pub action: SignedAction, +} + +/// Fixed-size replay-protection header for a [`SignedMessage`]. +#[repr(C)] +#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] +#[wincode(assert_zero_copy)] +pub struct MessageHeader { + /// Expected nonce value. Must match the nonce stored in the state account. + pub nonce: U32, + /// Unix timestamp after which the message expires. + /// Zero means the message does not expire. + pub deadline: I64, +} + +const _: () = assert!(core::mem::size_of::() == HEADER_LEN); + +/// Every post-initialization operation the authority can approve goes through +/// one of these variants. +#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] +#[wincode(tag_encoding = "u8")] +pub enum SignedAction { + /// Execute the signed CPI sequence. + Execute { + /// Account addresses referenced by CPI instructions. The program checks + /// that this table matches the `Submit` instruction's remaining + /// accounts in order, and CPI instructions reference this table by index. + #[wincode(with = "containers::Vec")] + account_table: Vec
, + /// CPI instructions to execute in order. Each instruction references + /// its program and accounts by index into + /// [`SignedAction::Execute`]'s `account_table`. + #[wincode(with = "containers::Vec")] + instructions: Vec, + }, + /// Increment the nonce without executing any CPI, invalidating all + /// previously signed messages for the account. + AdvanceNonce, + /// Close the nonce state account and refund its lamports. + Close { + /// Address that receives all lamports from the closed account. + recipient: Address, + }, +} + +/// A CPI instruction authorized by [`SignedAction::Execute`]. +#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] +pub struct CpiInstruction { + /// Index into [`SignedAction::Execute`]'s `account_table` for the target + /// program to invoke. + pub program_id_index: u8, + /// Per-account metadata for the CPI. + #[wincode(with = "containers::Vec")] + pub accounts: Vec, + /// Raw instruction data passed to the target program. + #[wincode(with = "containers::Vec")] + pub data: Vec, +} + +/// An account passed to a CPI, identified by its position in the signed +/// account table along with the signer and writable privileges the authority +/// approved for it. +#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] +pub struct AccountMeta { + /// Position of this account in [`SignedAction::Execute`]'s `account_table`. + pub account_index: u8, + /// Whether the authority approved this account as a signer for the CPI. + pub is_signer: bool, + /// Whether the authority approved this account as writable for the CPI. + pub is_writable: bool, +} diff --git a/interface/src/pda.rs b/interface/src/pda.rs new file mode 100644 index 0000000..0f73a0d --- /dev/null +++ b/interface/src/pda.rs @@ -0,0 +1,57 @@ +//! PDA derivation helpers for the SPL Nonce program. +//! +//! One [`AuthorityPolicy`] determines two canonical PDAs: +//! [`NonceStatePda`] and [`NonceAuthorityPda`]. + +use {crate::state::AuthorityPolicy, solana_address::Address}; + +/// Nonce state account PDA. Stores the nonce counter and authority policy. +/// +/// Seeds: `["nonce-state", authority_policy_hash, bump]` +pub struct NonceStatePda; + +impl NonceStatePda { + pub const SEED_PREFIX: &[u8] = b"nonce-state"; + + #[inline(always)] + pub fn derive_address_and_bump( + program_id: &Address, + authority_policy: &AuthorityPolicy, + ) -> (Address, u8) { + let authority_policy_hash = authority_policy.hash(); + Address::derive_program_address(&[Self::SEED_PREFIX, &authority_policy_hash], program_id) + .expect("failed to derive NonceStatePda from authority policy") + } + + #[inline(always)] + pub fn derive_address(program_id: &Address, authority_policy: &AuthorityPolicy) -> Address { + Self::derive_address_and_bump(program_id, authority_policy).0 + } +} + +/// Nonce authority PDA. +/// +/// The PDA the program signs as when executing committed CPI instructions. +/// Downstream programs can recognize this address as an owner or authority. +/// +/// Seeds: `["nonce-authority", authority_policy_hash, bump]` +pub struct NonceAuthorityPda; + +impl NonceAuthorityPda { + pub const SEED_PREFIX: &[u8] = b"nonce-authority"; + + #[inline(always)] + pub fn derive_address_and_bump( + program_id: &Address, + authority_policy: &AuthorityPolicy, + ) -> (Address, u8) { + let authority_policy_hash = authority_policy.hash(); + Address::derive_program_address(&[Self::SEED_PREFIX, &authority_policy_hash], program_id) + .expect("failed to derive NonceAuthorityPda from authority policy") + } + + #[inline(always)] + pub fn derive_address(program_id: &Address, authority_policy: &AuthorityPolicy) -> Address { + Self::derive_address_and_bump(program_id, authority_policy).0 + } +} diff --git a/interface/src/state.rs b/interface/src/state.rs new file mode 100644 index 0000000..560a900 --- /dev/null +++ b/interface/src/state.rs @@ -0,0 +1,48 @@ +use { + alloc::vec::Vec, + solana_address::Address, + solana_sha256_hasher::hashv, + solana_zero_copy::unaligned::U32, + wincode::{SchemaRead, SchemaWrite, containers}, +}; + +/// On-chain state for a nonce account. +#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] +pub struct NonceState { + /// Counter that prevents reuse of signed messages. A signed message must + /// reference this exact value. Each successful signed action increments + /// this value, invalidating any previously signed messages. + pub nonce: U32, + /// The set of keys authorized to sign actions for this account. + pub authority_policy: AuthorityPolicy, +} + +/// Signer policy that describes who is allowed to authorize use of a nonce account. +/// Supports both single-signer and threshold-multisig flows. +#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] +pub struct AuthorityPolicy { + /// Number of member approvals required to authorize execution. + pub threshold: u8, + /// Authority members in canonical ascending address-byte order. + /// The stored order is semantically meaningful: + /// - signature entries identify members by index in this list + /// - the policy hash commits to members in this order + #[wincode(with = "containers::Vec")] + pub members: Vec
, +} + +impl AuthorityPolicy { + /// Returns the SHA-256 digest of this policy, used as a seed in PDA + /// derivation for the nonce state account. The member list is hashed in + /// the stored order. + pub fn hash(&self) -> [u8; 32] { + let threshold = [self.threshold]; + let member_count = [self.members.len() as u8]; + let capacity = self.members.len().saturating_add(2); + let mut segments: Vec<&[u8]> = Vec::with_capacity(capacity); + segments.push(&threshold); + segments.push(&member_count); + segments.extend(self.members.iter().map(Address::as_ref)); + hashv(&segments).to_bytes() + } +} diff --git a/program/Cargo.toml b/program/Cargo.toml index 7503f8b..1f6964c 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -12,7 +12,7 @@ edition = {workspace = true} crate-type = ["cdylib"] [package.metadata.solana] -program-id = "2iZvRhbVukqhBXdKTpjmY5w2omXQbziFq1r5WkxSJKFD" +program-id = "nonce34S3Viw97xQwWGpRWEGufiSpfVEAiFe7Lefv7y" [lints] workspace = true @@ -21,4 +21,3 @@ workspace = true [dev-dependencies] - diff --git a/scripts/solana.dic b/scripts/solana.dic index 18581f7..6cb2421 100644 --- a/scripts/solana.dic +++ b/scripts/solana.dic @@ -43,6 +43,7 @@ timestamp/S entrypoint/S spl pda/S +PDA/S multisignature/S multisig/S staker/S @@ -52,3 +53,4 @@ autogenerated pinocchio Pinocchio IDL +Ed25519 From 25d24d1f45c02fb4aa50435645c78ef8a133ae22 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Tue, 21 Apr 2026 17:12:16 -0700 Subject: [PATCH 2/9] alt design: promote PDA to signer --- Cargo.lock | 44 ++------ interface/Cargo.toml | 10 +- interface/src/instruction.rs | 113 +++++++++++++++----- interface/src/lib.rs | 3 - interface/src/message.rs | 197 ----------------------------------- interface/src/pda.rs | 44 ++++---- interface/src/state.rs | 53 +++------- scripts/solana.dic | 2 +- 8 files changed, 134 insertions(+), 332 deletions(-) delete mode 100644 interface/src/message.rs diff --git a/Cargo.lock b/Cargo.lock index d4d401a..e473882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,9 +231,9 @@ checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" [[package]] name = "solana-address" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f08236dacd0e6dc8234becef58e27c8567856644ef509d7e97957d55a91dc72" +checksum = "f1384b52c435a750cc9c538760fc7bb472fd78e65a9900a2d07312c5bb335b72" dependencies = [ "curve25519-dalek", "five8", @@ -262,6 +262,9 @@ name = "solana-hash" version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1b113239362cee7093bfb250467138f079a2a03673181dc15bff6ccd677912d" +dependencies = [ + "wincode", +] [[package]] name = "solana-program-error" @@ -269,12 +272,6 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" -[[package]] -name = "solana-sanitize" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" - [[package]] name = "solana-sha256-hasher" version = "3.1.0" @@ -286,33 +283,12 @@ dependencies = [ "solana-hash", ] -[[package]] -name = "solana-signature" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a73c6e97cc2108be0adf6a6ea326434f8398df9d7eed81da2a4548b69e971c" -dependencies = [ - "five8", - "solana-sanitize", -] - -[[package]] -name = "solana-zero-copy" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f52dd8f733a13f6a18e55de83cf97c4c3f5fdf27ea3830bcff0b35313efcc2" -dependencies = [ - "wincode", -] - [[package]] name = "spl-nonce-interface" version = "0.1.0" dependencies = [ "solana-address", - "solana-sha256-hasher", - "solana-signature", - "solana-zero-copy", + "solana-hash", "wincode", ] @@ -383,9 +359,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wincode" -version = "0.4.9" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "657690780ce23e6f66576a782ffd88eb353512381817029cc1d7a99154bb6d1f" +checksum = "b4c754f1fc41250f2f742a27ba0fcc9f73df1dec23f6878490770855d43c322d" dependencies = [ "pastey", "proc-macro2", @@ -396,9 +372,9 @@ dependencies = [ [[package]] name = "wincode-derive" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca057fc9a13dd19cdb64ef558635d43c42667c0afa1ae7915ea1fa66993fd1a" +checksum = "3e070787599c7c067b89598cd3eda440cca1b69eda9e0ff7c725fc8679ce9eb4" dependencies = [ "darling", "proc-macro2", diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 52f7aa8..6e69b6c 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -18,10 +18,6 @@ program-id = "nonce34S3Viw97xQwWGpRWEGufiSpfVEAiFe7Lefv7y" workspace = true [dependencies] -# TODO: Need to update `solana-zero-copy` to use latest wincode before -# we can use the latest `solana-address` & wincode versions -solana-address = { version = "=2.5.0", features = ["curve25519", "syscalls", "decode", "wincode"] } -solana-sha256-hasher = { version = "3.1.0", default-features = false, features = ["sha2"] } -solana-signature = { version = "3.4.0", default-features = false } -solana-zero-copy = { version = "1.0.0", features = ["wincode"] } -wincode = { version = "0.4.9", features = ["derive"] } +solana-address = { version = "2.6.0", features = ["curve25519", "decode", "wincode"] } +solana-hash = { version = "4.3.0", features = ["wincode"] } +wincode = { version = "0.5.3", features = ["derive"] } diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 184cf94..798bf04 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -1,44 +1,101 @@ +use { + solana_address::Address, + wincode::{SchemaRead, SchemaWrite}, +}; + /// Instructions supported by the SPL Nonce program. #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NonceInstruction { - /// Creates a new nonce state account for the given authority policy. Anyone - /// may initialize the canonical pre-funded PDA for a given authority policy. + /// Creates a nonce account at the PDA derived from a caller-chosen 32-byte `nonce_id`. /// - /// On success, the state account is initialized with: - /// - `nonce = 0` - /// - the authority policy that will govern future signed actions + /// Instruction data: [`InitializeData`]. /// - /// The nonce state address is derived from [`NonceStatePda`](crate::pda::NonceStatePda) - /// and must already be pre-funded with enough lamports to be rent-exempt before - /// this instruction runs. During initialization, the program claims that PDA - /// as nonce state and writes the initial state into it. + /// On success, the program: + /// 1. Allocates and assigns the PDA via System program CPI. Caller must pre-fund it with + /// rent-exempt lamports. + /// 2. Derives the initial `nonce` as + /// `sha256("spl-nonce::init-v1" ‖ state_pda_address ‖ slot_hashes[0])`. + /// 3. Writes `NonceState { nonce, authority }` into the account data. /// /// Accounts required: - /// - `[writable]` Nonce state PDA to initialize (pre-funded) - /// - `[]` System program used to allocate and assign the pre-funded PDA + /// - `[writable]` `NonceStatePda`, pre-funded + /// - `[]` `SlotHashes` sysvar + /// - `[]` System program Initialize, - /// Verifies threshold Ed25519 signatures over a signed message, then - /// performs the action committed in that message. + /// Authorizes and executes a wrapped Solana transaction signed by `NonceState` authority. /// - /// The signed message specifies one of: - /// - `Execute`: run signed CPI instructions - /// - `AdvanceNonce`: increment the nonce, invalidating all previously signed messages - /// - `Close`: close the state account and refund lamports + /// Instruction data: `solana_transaction::Transaction`. /// /// On success, the program: - /// 1. Verifies that enough authority-policy members signed the message - /// 2. Checks the message nonce matches the state and the deadline has not passed - /// 3. Performs the committed action - /// - /// The instruction data format is defined in [`message`](crate::message). + /// 1. Deserializes the `Transaction` and sanitizes the wrapped message. + /// 2. Checks `message.account_keys[0] == state.authority`. + /// 3. Checks `message.recent_blockhash == state.nonce`. + /// 4. Verifies that every signer declared by the wrapped message either signs the + /// outer transaction or the inner wrapped transaction. + /// 5. Executes each `message.instructions` entry by CPI, promoting `NonceAuthorityPda` + /// to signer wherever referenced. + /// 6. Derives and stores the next nonce as + /// `sha256("spl-nonce::v1" ‖ state_pda ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(bincode(tx.message)))`. /// /// Accounts required: - /// - `[writable]` Nonce state PDA - /// - Remaining: accounts from the signed message's account table, in the - /// exact same order. `AdvanceNonce` requires no remaining accounts. + /// - `[writable]` `NonceStatePda` + /// - `[]` `SlotHashes` sysvar + /// - `[]` `Instructions` sysvar + /// - Remaining: `tx.message.account_keys`, in order, with `is_signer` and `is_writable` + /// flags matching the wrapped message. Submit, + + /// Rotates the authority controlling this nonce account. + /// + /// Instruction data: [`SetAuthorityData`]. + /// + /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit`. + /// Inherits the authorization from the outer `Submit`. A direct outer call cannot succeed, + /// because nothing outside this program can sign for `NonceAuthorityPda`. + /// + /// Accounts required: + /// - `[signer]` `NonceAuthorityPda` + /// - `[writable]` `NonceStatePda` + SetAuthority, + + /// Closes a nonce account and refunds its lamports. + /// + /// Instruction data: [`CloseData`]. + /// + /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit` + /// for the same reason as `SetAuthority`. + /// + /// Accounts required: + /// - `[signer]` `NonceAuthorityPda` + /// - `[writable]` `NonceStatePda` + /// - `[writable]` Lamport recipient + Close, +} + +/// Data for [`NonceInstruction::Initialize`]. +#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] +pub struct InitializeData { + /// Caller-chosen identifier for this nonce account. Each distinct value derives its own + /// [`NonceStatePda`](crate::pda::NonceStatePda). + pub nonce_id: [u8; 32], + /// Authorizes `Submit` ix for this account. + pub authority: Address, +} + +/// Data for [`NonceInstruction::SetAuthority`]. +#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] +pub struct SetAuthorityData { + /// Replacement authority address. + pub authority: Address, +} + +/// Data for [`NonceInstruction::Close`]. +#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] +pub struct CloseData { + /// Address that receives all lamports from the closed nonce account. + pub recipient: Address, } impl TryFrom for NonceInstruction { @@ -48,6 +105,8 @@ impl TryFrom for NonceInstruction { match value { 0 => Ok(Self::Initialize), 1 => Ok(Self::Submit), + 2 => Ok(Self::SetAuthority), + 3 => Ok(Self::Close), _ => Err(()), } } @@ -67,11 +126,13 @@ mod tests { fn discriminants_match() { assert_eq!(u8::from(NonceInstruction::Initialize), 0); assert_eq!(u8::from(NonceInstruction::Submit), 1); + assert_eq!(u8::from(NonceInstruction::SetAuthority), 2); + assert_eq!(u8::from(NonceInstruction::Close), 3); } #[test] fn try_from_rejects_unknown() { - assert!(NonceInstruction::try_from(2).is_err()); + assert!(NonceInstruction::try_from(4).is_err()); assert!(NonceInstruction::try_from(255).is_err()); } } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index b2e40c0..75cbdda 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -1,10 +1,7 @@ //! Interface for the Nonce program. #![no_std] -extern crate alloc; - pub mod instruction; -pub mod message; pub mod pda; pub mod state; diff --git a/interface/src/message.rs b/interface/src/message.rs deleted file mode 100644 index c6e3851..0000000 --- a/interface/src/message.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Wire-format types for offline-authorized signed messages submitted via `Submit`. -//! -//! Flow: -//! 1. Build a [`SignedMessage`]. -//! 2. Serialize it with `wincode`. -//! 3. Have authority-policy members sign those exact serialized [`SignedMessage`] -//! bytes offline using Ed25519. -//! 4. Construct [`InstructionData`] from the signatures and message. -//! 5. Submit that payload via [`NonceInstruction::Submit`](crate::instruction::NonceInstruction::Submit). -//! -//! The signatures cover only the serialized [`SignedMessage`], not the outer -//! Solana transaction. The transaction is just the transport that carries the -//! signed message to the program. -//! -//! ## Wire layout -//! -//! ```text -//! InstructionData -//! ┌───────────────┬────────────────────────┬────────────────────────┐ -//! │ discriminator │ signatures │ message │ -//! │ u8 │ count:u8 + entries │ SignedMessage │ -//! └───────────────┴────────────────────────┴────────────────────────┘ -//! -//! SignedMessage -//! ┌─────────┬──────────────────┬────────────────────────────────────┐ -//! │ version │ header │ action │ -//! │ u8 │ MessageHeader │ SignedAction │ -//! └─────────┴──────────────────┴────────────────────────────────────┘ -//! -//! MessageHeader -//! ┌──────────────┬──────────────────────────────┐ -//! │ nonce │ deadline │ -//! │ u32 LE │ i64 LE (0 = no expiration) │ -//! └──────────────┴──────────────────────────────┘ -//! ``` -//! -//! ### `SignedAction::Execute` -//! -//! ```text -//! ┌──────────────────────┬──────────────────────────────┐ -//! │ account_table │ instructions │ -//! │ count:u8 + addresses │ count:u8 + CpiInstructions │ -//! └──────────────────────┴──────────────────────────────┘ -//! ``` -//! -//! The `account_table` is the signed list of addresses that CPI instructions -//! reference by index. When the transaction is submitted, the caller must pass -//! those same addresses as remaining accounts on the `Submit` instruction, in -//! the same order: -//! -//! ```text -//! Submit accounts: -//! [0] NonceStatePda (always first) -//! [1] account_table[0] -//! [2] account_table[1] -//! [3] account_table[2] -//! ... -//! ``` -//! -//! The program checks that the submitted accounts match the signed table -//! exactly, preventing account substitution by the submitter. -//! -//! ### `SignedAction::AdvanceNonce` -//! -//! No payload. -//! -//! This action consumes the current nonce and increments it, invalidating all -//! previously signed messages for the account. -//! -//! ### `SignedAction::Close` -//! -//! ```text -//! ┌─────────────────────┐ -//! │ recipient: Address │ -//! └─────────────────────┘ -//! ``` -//! -//! The signed message specifies which address receives the lamports when the -//! nonce state account is closed. - -use { - alloc::vec::Vec, - solana_address::Address, - solana_signature::SIGNATURE_BYTES, - solana_zero_copy::unaligned::{I64, U32}, - wincode::{SchemaRead, SchemaWrite, containers}, -}; - -/// Current signed-message format version. -pub const SIGNED_MESSAGE_VERSION: u8 = 1; - -/// Serialized size of [`MessageHeader`] in bytes. -pub const HEADER_LEN: usize = 12; - -/// Full instruction-data body passed to -/// [`NonceInstruction::Submit`](crate::instruction::NonceInstruction::Submit). -#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] -pub struct InstructionData { - /// Must be `NonceInstruction::Submit` (1). - pub discriminator: u8, - /// Ed25519 signatures over the serialized [`InstructionData::message`]. - #[wincode(with = "containers::Vec")] - pub signatures: Vec, - /// The exact value authority-policy members sign. Contains the nonce, - /// deadline, and action the program verifies and executes. - pub message: SignedMessage, -} - -/// One authority-member approval attached to [`InstructionData`]. -#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] -pub struct SignatureEntry { - /// Index into [`AuthorityPolicy::members`](crate::state::AuthorityPolicy::members). - pub signer_index: u8, - /// Ed25519 signature over the serialized [`SignedMessage`]. - pub signature: [u8; SIGNATURE_BYTES], -} - -/// The message authority-policy members approve offline. -#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] -pub struct SignedMessage { - /// Format version. Must be [`SIGNED_MESSAGE_VERSION`]. - pub version: u8, - /// Replay-protection header containing the expected nonce and optional - /// deadline. - pub header: MessageHeader, - /// The exact action the authority approved. - pub action: SignedAction, -} - -/// Fixed-size replay-protection header for a [`SignedMessage`]. -#[repr(C)] -#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] -#[wincode(assert_zero_copy)] -pub struct MessageHeader { - /// Expected nonce value. Must match the nonce stored in the state account. - pub nonce: U32, - /// Unix timestamp after which the message expires. - /// Zero means the message does not expire. - pub deadline: I64, -} - -const _: () = assert!(core::mem::size_of::() == HEADER_LEN); - -/// Every post-initialization operation the authority can approve goes through -/// one of these variants. -#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] -#[wincode(tag_encoding = "u8")] -pub enum SignedAction { - /// Execute the signed CPI sequence. - Execute { - /// Account addresses referenced by CPI instructions. The program checks - /// that this table matches the `Submit` instruction's remaining - /// accounts in order, and CPI instructions reference this table by index. - #[wincode(with = "containers::Vec")] - account_table: Vec
, - /// CPI instructions to execute in order. Each instruction references - /// its program and accounts by index into - /// [`SignedAction::Execute`]'s `account_table`. - #[wincode(with = "containers::Vec")] - instructions: Vec, - }, - /// Increment the nonce without executing any CPI, invalidating all - /// previously signed messages for the account. - AdvanceNonce, - /// Close the nonce state account and refund its lamports. - Close { - /// Address that receives all lamports from the closed account. - recipient: Address, - }, -} - -/// A CPI instruction authorized by [`SignedAction::Execute`]. -#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] -pub struct CpiInstruction { - /// Index into [`SignedAction::Execute`]'s `account_table` for the target - /// program to invoke. - pub program_id_index: u8, - /// Per-account metadata for the CPI. - #[wincode(with = "containers::Vec")] - pub accounts: Vec, - /// Raw instruction data passed to the target program. - #[wincode(with = "containers::Vec")] - pub data: Vec, -} - -/// An account passed to a CPI, identified by its position in the signed -/// account table along with the signer and writable privileges the authority -/// approved for it. -#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] -pub struct AccountMeta { - /// Position of this account in [`SignedAction::Execute`]'s `account_table`. - pub account_index: u8, - /// Whether the authority approved this account as a signer for the CPI. - pub is_signer: bool, - /// Whether the authority approved this account as writable for the CPI. - pub is_writable: bool, -} diff --git a/interface/src/pda.rs b/interface/src/pda.rs index 0f73a0d..e633ef8 100644 --- a/interface/src/pda.rs +++ b/interface/src/pda.rs @@ -1,40 +1,38 @@ //! PDA derivation helpers for the SPL Nonce program. //! -//! One [`AuthorityPolicy`] determines two canonical PDAs: -//! [`NonceStatePda`] and [`NonceAuthorityPda`]. +//! A nonce account has two stable identities: +//! - [`NonceStatePda`], derived from an initialization nonce id +//! - [`NonceAuthorityPda`], derived from the nonce state address -use {crate::state::AuthorityPolicy, solana_address::Address}; +use solana_address::Address; -/// Nonce state account PDA. Stores the nonce counter and authority policy. +/// Nonce state account PDA. Stores the replay-protection nonce and authority. /// -/// Seeds: `["nonce-state", authority_policy_hash, bump]` +/// Seeds: `["nonce-state", nonce_id, bump]` pub struct NonceStatePda; impl NonceStatePda { pub const SEED_PREFIX: &[u8] = b"nonce-state"; #[inline(always)] - pub fn derive_address_and_bump( - program_id: &Address, - authority_policy: &AuthorityPolicy, - ) -> (Address, u8) { - let authority_policy_hash = authority_policy.hash(); - Address::derive_program_address(&[Self::SEED_PREFIX, &authority_policy_hash], program_id) - .expect("failed to derive NonceStatePda from authority policy") + pub fn derive_address_and_bump(program_id: &Address, nonce_id: &[u8; 32]) -> (Address, u8) { + Address::derive_program_address(&[Self::SEED_PREFIX, nonce_id], program_id) + .expect("failed to derive NonceStatePda from nonce id") } #[inline(always)] - pub fn derive_address(program_id: &Address, authority_policy: &AuthorityPolicy) -> Address { - Self::derive_address_and_bump(program_id, authority_policy).0 + pub fn derive_address(program_id: &Address, nonce_id: &[u8; 32]) -> Address { + let (address, _bump) = Self::derive_address_and_bump(program_id, nonce_id); + address } } /// Nonce authority PDA. /// -/// The PDA the program signs as when executing committed CPI instructions. -/// Downstream programs can recognize this address as an owner or authority. +/// Program-owned runtime signer for the nonce account. `Submit` promotes it to `is_signer=true` +/// via `invoke_signed` wherever the wrapped message references it. /// -/// Seeds: `["nonce-authority", authority_policy_hash, bump]` +/// Seeds: `["nonce-authority", nonce_state_addr, bump]` pub struct NonceAuthorityPda; impl NonceAuthorityPda { @@ -43,15 +41,15 @@ impl NonceAuthorityPda { #[inline(always)] pub fn derive_address_and_bump( program_id: &Address, - authority_policy: &AuthorityPolicy, + nonce_state_addr: &Address, ) -> (Address, u8) { - let authority_policy_hash = authority_policy.hash(); - Address::derive_program_address(&[Self::SEED_PREFIX, &authority_policy_hash], program_id) - .expect("failed to derive NonceAuthorityPda from authority policy") + Address::derive_program_address(&[Self::SEED_PREFIX, nonce_state_addr.as_ref()], program_id) + .expect("failed to derive NonceAuthorityPda from nonce state address") } #[inline(always)] - pub fn derive_address(program_id: &Address, authority_policy: &AuthorityPolicy) -> Address { - Self::derive_address_and_bump(program_id, authority_policy).0 + pub fn derive_address(program_id: &Address, nonce_state_address: &Address) -> Address { + let (address, _bump) = Self::derive_address_and_bump(program_id, nonce_state_address); + address } } diff --git a/interface/src/state.rs b/interface/src/state.rs index 560a900..c672d29 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -1,48 +1,19 @@ use { - alloc::vec::Vec, solana_address::Address, - solana_sha256_hasher::hashv, - solana_zero_copy::unaligned::U32, - wincode::{SchemaRead, SchemaWrite, containers}, + solana_hash::Hash, + wincode::{SchemaRead, SchemaWrite}, }; /// On-chain state for a nonce account. -#[derive(Clone, Debug, PartialEq, SchemaRead, SchemaWrite)] -pub struct NonceState { - /// Counter that prevents reuse of signed messages. A signed message must - /// reference this exact value. Each successful signed action increments - /// this value, invalidating any previously signed messages. - pub nonce: U32, - /// The set of keys authorized to sign actions for this account. - pub authority_policy: AuthorityPolicy, -} - -/// Signer policy that describes who is allowed to authorize use of a nonce account. -/// Supports both single-signer and threshold-multisig flows. #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] -pub struct AuthorityPolicy { - /// Number of member approvals required to authorize execution. - pub threshold: u8, - /// Authority members in canonical ascending address-byte order. - /// The stored order is semantically meaningful: - /// - signature entries identify members by index in this list - /// - the policy hash commits to members in this order - #[wincode(with = "containers::Vec")] - pub members: Vec
, -} - -impl AuthorityPolicy { - /// Returns the SHA-256 digest of this policy, used as a seed in PDA - /// derivation for the nonce state account. The member list is hashed in - /// the stored order. - pub fn hash(&self) -> [u8; 32] { - let threshold = [self.threshold]; - let member_count = [self.members.len() as u8]; - let capacity = self.members.len().saturating_add(2); - let mut segments: Vec<&[u8]> = Vec::with_capacity(capacity); - segments.push(&threshold); - segments.push(&member_count); - segments.extend(self.members.iter().map(Address::as_ref)); - hashv(&segments).to_bytes() - } +pub struct NonceState { + /// Single-use value that prevents a signed message from being replayed. `Submit` requires + /// the wrapped `Transaction`'s `message.recent_blockhash` to equal this. On success, + /// `Submit` advances it to a fresh hash over the prior nonce, `SlotHashes[0]`, and the + /// wrapped message bytes. + pub nonce: Hash, + /// First required signer of every `Submit`. Pinned at `tx.message.account_keys[0]`. + /// Any further signer positions in the wrapped message's signer prefix are verified + /// the same way (see `NonceInstruction::Submit`) but have no special status in state. + pub authority: Address, } diff --git a/scripts/solana.dic b/scripts/solana.dic index 6cb2421..8a8ac56 100644 --- a/scripts/solana.dic +++ b/scripts/solana.dic @@ -43,7 +43,6 @@ timestamp/S entrypoint/S spl pda/S -PDA/S multisignature/S multisig/S staker/S @@ -54,3 +53,4 @@ pinocchio Pinocchio IDL Ed25519 +tx From 326065d0bee57b4206837654aaf90687a948e99c Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Fri, 24 Apr 2026 19:07:51 -0700 Subject: [PATCH 3/9] update to v1 tx type --- interface/src/instruction.rs | 11 ++++++----- interface/src/state.rs | 7 +++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 798bf04..b9989c3 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -26,18 +26,19 @@ pub enum NonceInstruction { /// Authorizes and executes a wrapped Solana transaction signed by `NonceState` authority. /// - /// Instruction data: `solana_transaction::Transaction`. + /// Instruction data: serialized `solana_transaction::versioned::VersionedTransaction` + /// whose message is `solana_message::v1::Message`. /// /// On success, the program: - /// 1. Deserializes the `Transaction` and sanitizes the wrapped message. - /// 2. Checks `message.account_keys[0] == state.authority`. - /// 3. Checks `message.recent_blockhash == state.nonce`. + /// 1. Deserializes the transaction and sanitizes the wrapped message. + /// 2. Checks `state.authority` appears in the wrapped message's required-signer prefix. + /// 3. Checks `message.lifetime_specifier == state.nonce`. /// 4. Verifies that every signer declared by the wrapped message either signs the /// outer transaction or the inner wrapped transaction. /// 5. Executes each `message.instructions` entry by CPI, promoting `NonceAuthorityPda` /// to signer wherever referenced. /// 6. Derives and stores the next nonce as - /// `sha256("spl-nonce::v1" ‖ state_pda ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(bincode(tx.message)))`. + /// `sha256("spl-nonce::v1" ‖ state_pda ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(signed_message_bytes))` /// /// Accounts required: /// - `[writable]` `NonceStatePda` diff --git a/interface/src/state.rs b/interface/src/state.rs index c672d29..f050034 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -8,12 +8,11 @@ use { #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] pub struct NonceState { /// Single-use value that prevents a signed message from being replayed. `Submit` requires - /// the wrapped `Transaction`'s `message.recent_blockhash` to equal this. On success, + /// the wrapped v1 transaction message's lifetime specifier to equal this. On success, /// `Submit` advances it to a fresh hash over the prior nonce, `SlotHashes[0]`, and the /// wrapped message bytes. pub nonce: Hash, - /// First required signer of every `Submit`. Pinned at `tx.message.account_keys[0]`. - /// Any further signer positions in the wrapped message's signer prefix are verified - /// the same way (see `NonceInstruction::Submit`) but have no special status in state. + /// Address allowed to consume this nonce and advance its value. `Submit` verifies that this + /// address signed the wrapped transaction message. pub authority: Address, } From b2a1301eb99cf5e3a087a1c90b93a5950c38636b Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Fri, 24 Apr 2026 21:33:15 -0700 Subject: [PATCH 4/9] one pda per authority --- interface/src/instruction.rs | 27 +++++---------------------- interface/src/pda.rs | 14 +++++++------- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index b9989c3..44b418b 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -7,9 +7,7 @@ use { #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NonceInstruction { - /// Creates a nonce account at the PDA derived from a caller-chosen 32-byte `nonce_id`. - /// - /// Instruction data: [`InitializeData`]. + /// Creates the nonce state PDA for an authority. /// /// On success, the program: /// 1. Allocates and assigns the PDA via System program CPI. Caller must pre-fund it with @@ -48,25 +46,12 @@ pub enum NonceInstruction { /// flags matching the wrapped message. Submit, - /// Rotates the authority controlling this nonce account. - /// - /// Instruction data: [`SetAuthorityData`]. - /// - /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit`. - /// Inherits the authorization from the outer `Submit`. A direct outer call cannot succeed, - /// because nothing outside this program can sign for `NonceAuthorityPda`. - /// - /// Accounts required: - /// - `[signer]` `NonceAuthorityPda` - /// - `[writable]` `NonceStatePda` - SetAuthority, - /// Closes a nonce account and refunds its lamports. /// /// Instruction data: [`CloseData`]. /// - /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit` - /// for the same reason as `SetAuthority`. + /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit`, + /// because nothing outside this program can sign for `NonceAuthorityPda`. /// /// Accounts required: /// - `[signer]` `NonceAuthorityPda` @@ -106,8 +91,7 @@ impl TryFrom for NonceInstruction { match value { 0 => Ok(Self::Initialize), 1 => Ok(Self::Submit), - 2 => Ok(Self::SetAuthority), - 3 => Ok(Self::Close), + 2 => Ok(Self::Close), _ => Err(()), } } @@ -127,8 +111,7 @@ mod tests { fn discriminants_match() { assert_eq!(u8::from(NonceInstruction::Initialize), 0); assert_eq!(u8::from(NonceInstruction::Submit), 1); - assert_eq!(u8::from(NonceInstruction::SetAuthority), 2); - assert_eq!(u8::from(NonceInstruction::Close), 3); + assert_eq!(u8::from(NonceInstruction::Close), 2); } #[test] diff --git a/interface/src/pda.rs b/interface/src/pda.rs index e633ef8..e8e5400 100644 --- a/interface/src/pda.rs +++ b/interface/src/pda.rs @@ -1,28 +1,28 @@ //! PDA derivation helpers for the SPL Nonce program. //! //! A nonce account has two stable identities: -//! - [`NonceStatePda`], derived from an initialization nonce id +//! - [`NonceStatePda`], derived from the authority address //! - [`NonceAuthorityPda`], derived from the nonce state address use solana_address::Address; /// Nonce state account PDA. Stores the replay-protection nonce and authority. /// -/// Seeds: `["nonce-state", nonce_id, bump]` +/// Seeds: `["nonce-state", authority, bump]` pub struct NonceStatePda; impl NonceStatePda { pub const SEED_PREFIX: &[u8] = b"nonce-state"; #[inline(always)] - pub fn derive_address_and_bump(program_id: &Address, nonce_id: &[u8; 32]) -> (Address, u8) { - Address::derive_program_address(&[Self::SEED_PREFIX, nonce_id], program_id) - .expect("failed to derive NonceStatePda from nonce id") + pub fn derive_address_and_bump(program_id: &Address, authority: &Address) -> (Address, u8) { + Address::derive_program_address(&[Self::SEED_PREFIX, authority.as_ref()], program_id) + .expect("failed to derive NonceStatePda from authority") } #[inline(always)] - pub fn derive_address(program_id: &Address, nonce_id: &[u8; 32]) -> Address { - let (address, _bump) = Self::derive_address_and_bump(program_id, nonce_id); + pub fn derive_address(program_id: &Address, authority: &Address) -> Address { + let (address, _bump) = Self::derive_address_and_bump(program_id, authority); address } } From 3ab35171a774740cbcbc9d4d10d74b4e73399fe2 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Fri, 24 Apr 2026 21:34:20 -0700 Subject: [PATCH 5/9] require signer on Initialize --- interface/src/instruction.rs | 19 +------------------ interface/src/state.rs | 2 +- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 44b418b..ccf4ce2 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -17,8 +17,8 @@ pub enum NonceInstruction { /// 3. Writes `NonceState { nonce, authority }` into the account data. /// /// Accounts required: + /// - `[signer]` Authority whose address derives the nonce account /// - `[writable]` `NonceStatePda`, pre-funded - /// - `[]` `SlotHashes` sysvar /// - `[]` System program Initialize, @@ -60,23 +60,6 @@ pub enum NonceInstruction { Close, } -/// Data for [`NonceInstruction::Initialize`]. -#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] -pub struct InitializeData { - /// Caller-chosen identifier for this nonce account. Each distinct value derives its own - /// [`NonceStatePda`](crate::pda::NonceStatePda). - pub nonce_id: [u8; 32], - /// Authorizes `Submit` ix for this account. - pub authority: Address, -} - -/// Data for [`NonceInstruction::SetAuthority`]. -#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] -pub struct SetAuthorityData { - /// Replacement authority address. - pub authority: Address, -} - /// Data for [`NonceInstruction::Close`]. #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] pub struct CloseData { diff --git a/interface/src/state.rs b/interface/src/state.rs index f050034..ee4e04f 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -8,7 +8,7 @@ use { #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] pub struct NonceState { /// Single-use value that prevents a signed message from being replayed. `Submit` requires - /// the wrapped v1 transaction message's lifetime specifier to equal this. On success, + /// the wrapped `v1` transaction message's lifetime specifier to equal this. On success, /// `Submit` advances it to a fresh hash over the prior nonce, `SlotHashes[0]`, and the /// wrapped message bytes. pub nonce: Hash, From 1c0e2d0ae9c7f4df69ea5b6dde5ca25d1b0d78a4 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 30 Apr 2026 13:44:35 -0700 Subject: [PATCH 6/9] API update --- interface/src/instruction.rs | 64 ++++++++++++++++++++++-------------- interface/src/pda.rs | 47 +++++--------------------- interface/src/state.rs | 15 ++++++--- scripts/solana.dic | 1 + 4 files changed, 60 insertions(+), 67 deletions(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index ccf4ce2..5b01416 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -7,55 +7,71 @@ use { #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NonceInstruction { - /// Creates the nonce state PDA for an authority. + /// Initializes a nonce state account for an authority. + /// + /// The caller must first create and fund the nonce state account. Recommended to include + /// `solana_system_interface::instruction::create_account` and `Initialize` in the same + /// transaction so no other transaction can initialize the account first. /// /// On success, the program: - /// 1. Allocates and assigns the PDA via System program CPI. Caller must pre-fund it with - /// rent-exempt lamports. + /// 1. Verifies the nonce state account is uninitialized, rent-exempt, and owned by + /// the nonce program. /// 2. Derives the initial `nonce` as - /// `sha256("spl-nonce::init-v1" ‖ state_pda_address ‖ slot_hashes[0])`. + /// `sha256("spl-nonce::init-v1" ‖ nonce_state_address ‖ slot_hashes[0])`. /// 3. Writes `NonceState { nonce, authority }` into the account data. /// + /// Instruction data: empty. + /// /// Accounts required: - /// - `[signer]` Authority whose address derives the nonce account - /// - `[writable]` `NonceStatePda`, pre-funded - /// - `[]` System program + /// - `[writable]` Nonce state account + /// - `[]` Authority to store in the nonce state account + /// - `[]` `SlotHashes` sysvar Initialize, - /// Authorizes and executes a wrapped Solana transaction signed by `NonceState` authority. + /// Authorizes and executes a wrapped Solana transaction whose required signers are + /// `NonceAuthorityPda` accounts. + /// + /// Instruction data: serialized `solana_transaction::versioned::VersionedTransaction`. + /// All message variants supported by `VersionedTransaction` are accepted. /// - /// Instruction data: serialized `solana_transaction::versioned::VersionedTransaction` - /// whose message is `solana_message::v1::Message`. + /// Wrapped required signers are paired by index: + /// - `message.account_keys[i]`: `NonceAuthorityPda` promoted during CPI. + /// - `tx.signatures[i]`: wrapped-message signature from the matching authority address. /// /// On success, the program: /// 1. Deserializes the transaction and sanitizes the wrapped message. - /// 2. Checks `state.authority` appears in the wrapped message's required-signer prefix. - /// 3. Checks `message.lifetime_specifier == state.nonce`. - /// 4. Verifies that every signer declared by the wrapped message either signs the - /// outer transaction or the inner wrapped transaction. - /// 5. Executes each `message.instructions` entry by CPI, promoting `NonceAuthorityPda` - /// to signer wherever referenced. - /// 6. Derives and stores the next nonce as - /// `sha256("spl-nonce::v1" ‖ state_pda ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(signed_message_bytes))` + /// 2. Reads the authority stored in the nonce state account. + /// 3. Checks the passed nonce state account's authority signed the wrapped message. + /// 4. Checks the wrapped message's lifetime / recent blockhash field equals `state.nonce`. + /// 5. Verifies the outer transaction's only top-level instruction is `Submit`. + /// 6. For each wrapped required signer position `i`, requires + /// `NonceAuthorityPda(authority_i) == message.account_keys[i]` and verifies + /// `tx.signatures[i]` over the wrapped message with `authority_i`. + /// 7. Executes each `message.instructions` entry by CPI, using `invoke_signed` to promote + /// each authorized signer's corresponding `NonceAuthorityPda`. + /// 8. Derives and stores the next nonce as + /// `sha256("spl-nonce::v1" ‖ nonce_state ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(signed_message_bytes))` /// /// Accounts required: - /// - `[writable]` `NonceStatePda` + /// - `[writable]` Nonce state account whose nonce is consumed and advanced /// - `[]` `SlotHashes` sysvar /// - `[]` `Instructions` sysvar - /// - Remaining: `tx.message.account_keys`, in order, with `is_signer` and `is_writable` - /// flags matching the wrapped message. + /// - Required-signer authority addresses, ordered to match the wrapped required signers: + /// `NonceAuthorityPda(authority_i) == message.account_keys[i]`. + /// - Remaining: all accounts referenced by the wrapped message, in order, with `is_signer` + /// and `is_writable` flags matching the wrapped message. Submit, - /// Closes a nonce account and refunds its lamports. + /// Closes a nonce state account and refunds its lamports. /// /// Instruction data: [`CloseData`]. /// - /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit`, + /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit` /// because nothing outside this program can sign for `NonceAuthorityPda`. /// /// Accounts required: /// - `[signer]` `NonceAuthorityPda` - /// - `[writable]` `NonceStatePda` + /// - `[writable]` Nonce state account /// - `[writable]` Lamport recipient Close, } diff --git a/interface/src/pda.rs b/interface/src/pda.rs index e8e5400..504995e 100644 --- a/interface/src/pda.rs +++ b/interface/src/pda.rs @@ -1,55 +1,26 @@ -//! PDA derivation helpers for the SPL Nonce program. -//! -//! A nonce account has two stable identities: -//! - [`NonceStatePda`], derived from the authority address -//! - [`NonceAuthorityPda`], derived from the nonce state address - use solana_address::Address; -/// Nonce state account PDA. Stores the replay-protection nonce and authority. -/// -/// Seeds: `["nonce-state", authority, bump]` -pub struct NonceStatePda; - -impl NonceStatePda { - pub const SEED_PREFIX: &[u8] = b"nonce-state"; - - #[inline(always)] - pub fn derive_address_and_bump(program_id: &Address, authority: &Address) -> (Address, u8) { - Address::derive_program_address(&[Self::SEED_PREFIX, authority.as_ref()], program_id) - .expect("failed to derive NonceStatePda from authority") - } - - #[inline(always)] - pub fn derive_address(program_id: &Address, authority: &Address) -> Address { - let (address, _bump) = Self::derive_address_and_bump(program_id, authority); - address - } -} - /// Nonce authority PDA. /// -/// Program-owned runtime signer for the nonce account. `Submit` promotes it to `is_signer=true` -/// via `invoke_signed` wherever the wrapped message references it. +/// Program-owned runtime signer for an authority. `Submit` promotes it to `is_signer=true` +/// via `invoke_signed` wherever a wrapped instruction references it after the corresponding +/// authority has signed the wrapped message. /// -/// Seeds: `["nonce-authority", nonce_state_addr, bump]` +/// Seeds: `["nonce-authority", authority, bump]` pub struct NonceAuthorityPda; impl NonceAuthorityPda { pub const SEED_PREFIX: &[u8] = b"nonce-authority"; #[inline(always)] - pub fn derive_address_and_bump( - program_id: &Address, - nonce_state_addr: &Address, - ) -> (Address, u8) { - Address::derive_program_address(&[Self::SEED_PREFIX, nonce_state_addr.as_ref()], program_id) - .expect("failed to derive NonceAuthorityPda from nonce state address") + pub fn derive_address_and_bump(program_id: &Address, authority: &Address) -> (Address, u8) { + Address::derive_program_address(&[Self::SEED_PREFIX, authority.as_ref()], program_id) + .expect("failed to derive NonceAuthorityPda from authority") } #[inline(always)] - pub fn derive_address(program_id: &Address, nonce_state_address: &Address) -> Address { - let (address, _bump) = Self::derive_address_and_bump(program_id, nonce_state_address); + pub fn derive_address(program_id: &Address, authority: &Address) -> Address { + let (address, _bump) = Self::derive_address_and_bump(program_id, authority); address } } diff --git a/interface/src/state.rs b/interface/src/state.rs index ee4e04f..5e5b336 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -4,13 +4,18 @@ use { wincode::{SchemaRead, SchemaWrite}, }; -/// On-chain state for a nonce account. +/// On-chain state for a nonce account. Caller-created and owned by the nonce program. +/// +/// One authority can control any number of independent nonce state accounts. This is useful for +/// when that authority wants to prepare or submit more than one transaction concurrently. Each +/// account carries its own nonce, so consuming one nonce does not advance or invalidate +/// transactions prepared against another nonce state account. #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] pub struct NonceState { - /// Single-use value that prevents a signed message from being replayed. `Submit` requires - /// the wrapped `v1` transaction message's lifetime specifier to equal this. On success, - /// `Submit` advances it to a fresh hash over the prior nonce, `SlotHashes[0]`, and the - /// wrapped message bytes. + /// Single-use value that prevents a signed message from being replayed. `Submit` requires this + /// to match the wrapped message's lifetime field: `lifetime_specifier` for `v1` messages or + /// `recent_blockhash` for `legacy` and `v0` messages. On success, `Submit` advances it to a + /// fresh hash over the prior nonce, `SlotHashes[0]`, and the wrapped message bytes. pub nonce: Hash, /// Address allowed to consume this nonce and advance its value. `Submit` verifies that this /// address signed the wrapped transaction message. diff --git a/scripts/solana.dic b/scripts/solana.dic index 8a8ac56..c130ba0 100644 --- a/scripts/solana.dic +++ b/scripts/solana.dic @@ -54,3 +54,4 @@ Pinocchio IDL Ed25519 tx +blockhash From f46afdbd3b3e2c8b6290b9efcbab6f9d72add5cb Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Tue, 5 May 2026 17:36:46 -0400 Subject: [PATCH 7/9] change to spl-ed25519-durable-signer terms --- Cargo.lock | 4 +-- README.md | 2 +- interface/Cargo.toml | 4 +-- interface/src/instruction.rs | 70 ++++++++++++++++++------------------ interface/src/lib.rs | 2 +- interface/src/pda.rs | 12 +++---- interface/src/state.rs | 6 ++-- program/Cargo.toml | 4 +-- program/src/lib.rs | 2 +- 9 files changed, 53 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e473882..be072c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,7 +284,7 @@ dependencies = [ ] [[package]] -name = "spl-nonce-interface" +name = "spl-ed25519-durable-signer-interface" version = "0.1.0" dependencies = [ "solana-address", @@ -293,7 +293,7 @@ dependencies = [ ] [[package]] -name = "spl-nonce-program" +name = "spl-ed25519-durable-signer-program" version = "0.1.0" [[package]] diff --git a/README.md b/README.md index b0ccb63..ef244e1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# Nonce Program +# Ed25519 Durable Signer Program Under construction 🚧 \ No newline at end of file diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 6e69b6c..0f38ab4 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "spl-nonce-interface" +name = "spl-ed25519-durable-signer-interface" version = "0.1.0" -description = "Interface for the SPL Nonce program" +description = "Interface for the SPL Ed25519 Durable Signer program" authors = { workspace = true } repository = { workspace = true } homepage = { workspace = true } diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 5b01416..6ad7bc9 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -3,87 +3,87 @@ use { wincode::{SchemaRead, SchemaWrite}, }; -/// Instructions supported by the SPL Nonce program. +/// Instructions supported by the SPL Ed25519 Durable Signer program. #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NonceInstruction { - /// Initializes a nonce state account for an authority. +pub enum DurableSignerInstruction { + /// Initializes a durable signer account for an authority. /// - /// The caller must first create and fund the nonce state account. Recommended to include + /// The caller must first create and fund the account. Recommended to include /// `solana_system_interface::instruction::create_account` and `Initialize` in the same /// transaction so no other transaction can initialize the account first. /// /// On success, the program: - /// 1. Verifies the nonce state account is uninitialized, rent-exempt, and owned by - /// the nonce program. + /// 1. Verifies the account is uninitialized, rent-exempt, and owned by this program. /// 2. Derives the initial `nonce` as - /// `sha256("spl-nonce::init-v1" ‖ nonce_state_address ‖ slot_hashes[0])`. - /// 3. Writes `NonceState { nonce, authority }` into the account data. + /// `sha256("spl-ed25519-durable-signer::init-v1" ‖ durable_signer_account_address ‖ slot_hashes[0])`. + /// 3. Writes `DurableSignerAccount { nonce, authority }` into the account data. /// /// Instruction data: empty. /// /// Accounts required: - /// - `[writable]` Nonce state account - /// - `[]` Authority to store in the nonce state account + /// - `[writable]` Durable signer account + /// - `[]` Authority to store in the durable signer account /// - `[]` `SlotHashes` sysvar Initialize, /// Authorizes and executes a wrapped Solana transaction whose required signers are - /// `NonceAuthorityPda` accounts. + /// `DurableSignerPda` accounts. /// /// Instruction data: serialized `solana_transaction::versioned::VersionedTransaction`. /// All message variants supported by `VersionedTransaction` are accepted. /// /// Wrapped required signers are paired by index: - /// - `message.account_keys[i]`: `NonceAuthorityPda` promoted during CPI. + /// - `message.account_keys[i]`: `DurableSignerPda` promoted during CPI. /// - `tx.signatures[i]`: wrapped-message signature from the matching authority address. /// /// On success, the program: /// 1. Deserializes the transaction and sanitizes the wrapped message. - /// 2. Reads the authority stored in the nonce state account. - /// 3. Checks the passed nonce state account's authority signed the wrapped message. - /// 4. Checks the wrapped message's lifetime / recent blockhash field equals `state.nonce`. + /// 2. Reads the authority stored in the durable signer account. + /// 3. Checks the passed durable signer account's authority signed the wrapped message. + /// 4. Checks the wrapped message's lifetime / recent blockhash field equals the account's + /// `nonce`. /// 5. Verifies the outer transaction's only top-level instruction is `Submit`. /// 6. For each wrapped required signer position `i`, requires - /// `NonceAuthorityPda(authority_i) == message.account_keys[i]` and verifies + /// `DurableSignerPda(authority_i) == message.account_keys[i]` and verifies /// `tx.signatures[i]` over the wrapped message with `authority_i`. /// 7. Executes each `message.instructions` entry by CPI, using `invoke_signed` to promote - /// each authorized signer's corresponding `NonceAuthorityPda`. + /// each authorized signer's corresponding `DurableSignerPda`. /// 8. Derives and stores the next nonce as - /// `sha256("spl-nonce::v1" ‖ nonce_state ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(signed_message_bytes))` + /// `sha256("spl-ed25519-durable-signer::v1" ‖ durable_signer_account ‖ old_nonce ‖ slot_hashes[0] ‖ sha256(signed_message_bytes))` /// /// Accounts required: - /// - `[writable]` Nonce state account whose nonce is consumed and advanced + /// - `[writable]` Durable signer account whose nonce is consumed and advanced /// - `[]` `SlotHashes` sysvar /// - `[]` `Instructions` sysvar /// - Required-signer authority addresses, ordered to match the wrapped required signers: - /// `NonceAuthorityPda(authority_i) == message.account_keys[i]`. + /// `DurableSignerPda(authority_i) == message.account_keys[i]`. /// - Remaining: all accounts referenced by the wrapped message, in order, with `is_signer` /// and `is_writable` flags matching the wrapped message. Submit, - /// Closes a nonce state account and refunds its lamports. + /// Closes a durable signer account and refunds its lamports. /// /// Instruction data: [`CloseData`]. /// /// Runs only as an inner instruction of a wrapped transaction submitted through `Submit` - /// because nothing outside this program can sign for `NonceAuthorityPda`. + /// because nothing outside this program can sign for `DurableSignerPda`. /// /// Accounts required: - /// - `[signer]` `NonceAuthorityPda` - /// - `[writable]` Nonce state account + /// - `[signer]` `DurableSignerPda` + /// - `[writable]` Durable signer account /// - `[writable]` Lamport recipient Close, } -/// Data for [`NonceInstruction::Close`]. +/// Data for [`DurableSignerInstruction::Close`]. #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] pub struct CloseData { - /// Address that receives all lamports from the closed nonce account. + /// Address that receives all lamports from the closed durable signer account. pub recipient: Address, } -impl TryFrom for NonceInstruction { +impl TryFrom for DurableSignerInstruction { type Error = (); fn try_from(value: u8) -> Result { @@ -96,26 +96,26 @@ impl TryFrom for NonceInstruction { } } -impl From for u8 { - fn from(value: NonceInstruction) -> Self { +impl From for u8 { + fn from(value: DurableSignerInstruction) -> Self { value as u8 } } #[cfg(test)] mod tests { - use super::NonceInstruction; + use super::DurableSignerInstruction; #[test] fn discriminants_match() { - assert_eq!(u8::from(NonceInstruction::Initialize), 0); - assert_eq!(u8::from(NonceInstruction::Submit), 1); - assert_eq!(u8::from(NonceInstruction::Close), 2); + assert_eq!(u8::from(DurableSignerInstruction::Initialize), 0); + assert_eq!(u8::from(DurableSignerInstruction::Submit), 1); + assert_eq!(u8::from(DurableSignerInstruction::Close), 2); } #[test] fn try_from_rejects_unknown() { - assert!(NonceInstruction::try_from(4).is_err()); - assert!(NonceInstruction::try_from(255).is_err()); + assert!(DurableSignerInstruction::try_from(4).is_err()); + assert!(DurableSignerInstruction::try_from(255).is_err()); } } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 75cbdda..b39e392 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -1,4 +1,4 @@ -//! Interface for the Nonce program. +//! Interface for the Ed25519 Durable Signer program. #![no_std] pub mod instruction; diff --git a/interface/src/pda.rs b/interface/src/pda.rs index 504995e..47cef8b 100644 --- a/interface/src/pda.rs +++ b/interface/src/pda.rs @@ -1,21 +1,21 @@ use solana_address::Address; -/// Nonce authority PDA. +/// Durable signer PDA. /// /// Program-owned runtime signer for an authority. `Submit` promotes it to `is_signer=true` /// via `invoke_signed` wherever a wrapped instruction references it after the corresponding /// authority has signed the wrapped message. /// -/// Seeds: `["nonce-authority", authority, bump]` -pub struct NonceAuthorityPda; +/// Seeds: `["durable-signer", authority, bump]` +pub struct DurableSignerPda; -impl NonceAuthorityPda { - pub const SEED_PREFIX: &[u8] = b"nonce-authority"; +impl DurableSignerPda { + pub const SEED_PREFIX: &[u8] = b"durable-signer"; #[inline(always)] pub fn derive_address_and_bump(program_id: &Address, authority: &Address) -> (Address, u8) { Address::derive_program_address(&[Self::SEED_PREFIX, authority.as_ref()], program_id) - .expect("failed to derive NonceAuthorityPda from authority") + .expect("failed to derive DurableSignerPda from authority") } #[inline(always)] diff --git a/interface/src/state.rs b/interface/src/state.rs index 5e5b336..6aebd56 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -4,14 +4,14 @@ use { wincode::{SchemaRead, SchemaWrite}, }; -/// On-chain state for a nonce account. Caller-created and owned by the nonce program. +/// On-chain data for a caller-created durable signer account. /// -/// One authority can control any number of independent nonce state accounts. This is useful for +/// One authority can control any number of independent durable signer accounts. This is useful for /// when that authority wants to prepare or submit more than one transaction concurrently. Each /// account carries its own nonce, so consuming one nonce does not advance or invalidate /// transactions prepared against another nonce state account. #[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)] -pub struct NonceState { +pub struct DurableSignerAccount { /// Single-use value that prevents a signed message from being replayed. `Submit` requires this /// to match the wrapped message's lifetime field: `lifetime_specifier` for `v1` messages or /// `recent_blockhash` for `legacy` and `v0` messages. On success, `Submit` advances it to a diff --git a/program/Cargo.toml b/program/Cargo.toml index 1f6964c..9913052 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "spl-nonce-program" +name = "spl-ed25519-durable-signer-program" version = "0.1.0" -description = "TBD" +description = "SPL Ed25519 Durable Signer program" authors = {workspace = true} repository = {workspace = true} homepage = {workspace = true} diff --git a/program/src/lib.rs b/program/src/lib.rs index aa629e9..a3bd66d 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1 +1 @@ -//! Nonce program. +//! Ed25519 Durable Signer program. From 40b9ffa75bfeac4388a464976bd67dd76d4ae6bd Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Tue, 5 May 2026 18:45:52 -0400 Subject: [PATCH 8/9] update `Submit` code docs --- interface/src/instruction.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 6ad7bc9..77694f8 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -44,7 +44,7 @@ pub enum DurableSignerInstruction { /// 4. Checks the wrapped message's lifetime / recent blockhash field equals the account's /// `nonce`. /// 5. Verifies the outer transaction's only top-level instruction is `Submit`. - /// 6. For each wrapped required signer position `i`, requires + /// 6. Iterates over the outer authority accounts in order. For each `authority_i`, requires /// `DurableSignerPda(authority_i) == message.account_keys[i]` and verifies /// `tx.signatures[i]` over the wrapped message with `authority_i`. /// 7. Executes each `message.instructions` entry by CPI, using `invoke_signed` to promote @@ -56,10 +56,9 @@ pub enum DurableSignerInstruction { /// - `[writable]` Durable signer account whose nonce is consumed and advanced /// - `[]` `SlotHashes` sysvar /// - `[]` `Instructions` sysvar - /// - Required-signer authority addresses, ordered to match the wrapped required signers: - /// `DurableSignerPda(authority_i) == message.account_keys[i]`. - /// - Remaining: all accounts referenced by the wrapped message, in order, with `is_signer` - /// and `is_writable` flags matching the wrapped message. + /// - Authority addresses, ordered to match the wrapped message's required signers. + /// - Remaining accounts referenced by the wrapped message, in order. Writable flags + /// must match the wrapped message. Submit, /// Closes a durable signer account and refunds its lamports. From 75dd928cf422e6aa9071b14c6b0d3152d235bb39 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Tue, 5 May 2026 19:03:08 -0400 Subject: [PATCH 9/9] addr update --- interface/Cargo.toml | 2 +- program/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 0f38ab4..91e2ace 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -12,7 +12,7 @@ edition = { workspace = true } crate-type = ["rlib"] [package.metadata.solana] -program-id = "nonce34S3Viw97xQwWGpRWEGufiSpfVEAiFe7Lefv7y" +program-id = "EdSigVfK1DkeMrjFNDMjwfQaJPhPTtX7jW8uPv3oKEgN" [lints] workspace = true diff --git a/program/Cargo.toml b/program/Cargo.toml index 9913052..0d537b6 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -12,7 +12,7 @@ edition = {workspace = true} crate-type = ["cdylib"] [package.metadata.solana] -program-id = "nonce34S3Viw97xQwWGpRWEGufiSpfVEAiFe7Lefv7y" +program-id = "EdSigVfK1DkeMrjFNDMjwfQaJPhPTtX7jW8uPv3oKEgN" [lints] workspace = true