Skip to content

Base interfaces & data structures#1

Open
grod220 wants to merge 1 commit intomainfrom
base-ixs
Open

Base interfaces & data structures#1
grod220 wants to merge 1 commit intomainfrom
base-ixs

Conversation

@grod220
Copy link
Copy Markdown
Member

@grod220 grod220 commented Apr 12, 2026

This PR adds base interfaces for the program: instructions, state, message schema, and PDA types.

Overall objective

Write a program that can serve as a functional replacement for the durable nonce usecase:

  • Approve something offline
  • Submit it later from a hot environment
  • Prevent replay
  • Support cold-signing and threshold-multisig workflows

The difference is that durable nonces do this via special runtime functionality (that folks are interested in removing), while this program does it through a signed program message.

Instead of signing a full transaction offline, authority members sign a structured SignedMessage that commits to the exact action being approved. It doesn't require any special features and can work today. Even further, it can enable additional features like signature expiry.

Inspiration

Trent's durable nonce replacement proposal: solana-foundation/solana-improvement-documents#456

High-level design

  • NonceState: which stores the current nonce plus the AuthorityPolicy
  • NonceStatePda: the PDA that stores state
  • NonceAuthorityPda: the PDA the program signs as during CPI
  • InstructionData / SignedMessage: the offline-signed authorization format
  • SignedAction: Supported actions that can be executed by the program

The intended flow is:

  1. Derive and initialize the canonical nonce state PDA for an authority policy
  2. Build and serialize a SignedMessage
  3. Have authority members sign those exact bytes offline
  4. Submit the signed message on-chain later via Submit
  5. Program then:
    1. Verifies signatures, nonce, and deadline
    2. Performs the approved action
    3. Increments the nonce

Divergences from original proposal

This follows the spirit of Trent's original proposal, but there are a few divergences:

  • Does not use Vault naming as I felt it had too much overlap with the idea of custody and the defi vault concept.
  • The signed message commits to the full CPI account table, so account substitution is not possible
  • The signed message commits to per-account signer and writable privileges
  • The signed header includes an optional deadline
  • Authority is modeled as an AuthorityPolicy with threshold plus ordered members, rather than a single authority key
  • Signer seeds are not passed in the payload. The program derives one canonical authority PDA from the authority policy.

What's next

  • Add Codama support
  • Generate clients
  • Program implementation
  • Tests
  • CLI helpers
  • Benchmarking

@grod220 grod220 requested review from joncinque and t-nelson April 12, 2026 19:38
Copy link
Copy Markdown

@t-nelson t-nelson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good start!

//! ```text
//! ┌──────────────────────┬──────────────────────────────┐
//! │ account_table │ instructions │
//! │ count:u8 + addresses │ count:u8 + CpiInstructions │
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than referencing the addresses directly, i was thinking that we just commit to their hash

pub nonce: U32,
/// Unix timestamp after which the message expires.
/// Zero means the message does not expire.
pub deadline: I64,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the timelock feature could theoretically be a standalone cpi-passthrough program


if we want to keep it here, consider Option<NonZeroI64>

#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)]
pub struct SignatureEntry {
/// Index into [`AuthorityPolicy::members`](crate::state::AuthorityPolicy::members).
pub signer_index: u8,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to assert that the index is implied by signer position in the instruction's accounts list, like we do with transactions

/// account table along with the signer and writable privileges the authority
/// approved for it.
#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)]
pub struct AccountMeta {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to recover these from AccountInfo?

Comment on lines +194 to +196
pub is_signer: bool,
/// Whether the authority approved this account as writable for the CPI.
pub is_writable: bool,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we decide to keep the struct, it may pay to serialize the vec as something like this to avoid the 14bit overhead

Encoded {
    indexes: Vec<u8>,
    roles: BitField,
}


#[inline(always)]
pub fn derive_address(program_id: &Address, authority_policy: &AuthorityPolicy) -> Address {
Self::derive_address_and_bump(program_id, authority_policy).0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: tuple index notation is poison

Suggested change
Self::derive_address_and_bump(program_id, authority_policy).0
let (address, _bump =
Self::derive_address_and_bump(program_id, authority_policy);
address

#[inline(always)]
pub fn derive_address_and_bump(
program_id: &Address,
authority_policy: &AuthorityPolicy,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think forcing the signer to be derived from the authority policy risks compromising authority should sufficient signers become malicious/unavailable? it certainly seems to preclude modifying the signer set in the future


#[inline(always)]
pub fn derive_address(program_id: &Address, authority_policy: &AuthorityPolicy) -> Address {
Self::derive_address_and_bump(program_id, authority_policy).0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

segments.push(&threshold);
segments.push(&member_count);
segments.extend(self.members.iter().map(Address::as_ref));
hashv(&segments).to_bytes()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow i was going to recommend dumping the phone guy abstraction for a normal hashing interface and we can avoid the vec construction, but somehow we chose to keep the dumbest interface. if we were any good at interfaces, you'd be able to do something like this 🫠

let mut hasher = Hasher::new();
hasher.update(&threshold);
hasher.update(&member_count);
self.members.iter().for_each(hasher.update);
hasher.finalize().to_bytes();

#[derive(Clone, Debug, PartialEq, Eq, SchemaRead, SchemaWrite)]
pub struct AuthorityPolicy {
/// Number of member approvals required to authorize execution.
pub threshold: u8,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

multisig could also be a standalone cpi-passthrough program. imagine this glorious future 🤓

sign(sign(sign(timelock(multisig(transfer())))))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants