Rust
Secp256r1 on Solana

Secp256r1 on Solana

Secp256r1 with Pinocchio

Dean from the Blueshift team has released the first crate that enables Pinocchio compatibility for verifying instructions that execute the Secp256r1 precompile.

This is particularly useful for implementing modern authentication methods like passkeys in Pinocchio programs.

Introduction

The SDK provides clean abstractions through core data structures:

// 33-byte compressed public key (1 byte parity + 32 byte x-coordinate)
pub type Secp256r1Pubkey = [u8; 33];
// 64-byte signature (r,s values)
pub type Secp256r1Signature = [u8; 64];
 
// Main instruction parser
pub struct Secp256r1Instruction<'a> {
    header: Secp256r1InstructionHeader,    // Number of signatures
    offsets: &'a [Secp256r1SignatureOffsets], // Data location pointers
    data: &'a [u8],                        // Raw instruction data
}

The Secp256r1SignatureOffsets struct acts as a memory map, containing byte offsets that point to where each component lives within the instruction payload:

pub struct Secp256r1SignatureOffsets {
    pub signature_offset: u16,
    pub signature_instruction_index: u16,
    pub public_key_offset: u16,
    pub public_key_instruction_index: u16,
    pub message_data_offset: u16,
    pub message_data_size: u16,
    pub message_instruction_index: u16,
}

In the data, we find the three critical components referenced by the offset structure:

  • Publickey: The 33-byte compressed Secp256r1 public key. When used with modern authentication methods like passkeys, this represents the cryptographic identity of the authenticating device/user.
  • Signature: The 64-byte ECDSA signature (r,s values) generated by the private key. This proves that the holder of the corresponding private key authorized the specific message.
  • Message Data: The arbitrary bytes that were cryptographically signed. In practice, this contains application-specific data like transaction details, timestamps, or user identifiers that prevent replay attacks and ensure signatures are contextually bound.

As you can see, the publickey is 33 byte long because it uses a compressed point representation; a space-efficient encoding of elliptic curve points.

On Secp256r1, a public key is mathematically a point (x,y) where both coordinates are 32 bytes (64 bytes total).

However, given any x-coordinate, only two possible y-coordinates satisfy the curve equation.

The compressed format stores the 32-byte x-coordinate plus a single parity byte (0x02 for even y, 0x03 for odd y), allowing full point reconstruction with 48% less storage.

Implementation

To verify Secp256r1 signatures, we need two main components:

  1. The instruction sysvar: This allows us to introspect the Secp256r1 signature
  2. The pinocchio-secp256r1-instruction crate: This provides the tools to deserialize the instruction

The instruction sysvar is already included in the Pinocchio crate, so no additional installation is needed.

However, we need to add the pinocchio-secp256r1-instruction crate to our Pinocchio program:

cargo add pinocchio-secp256r1-instruction

To implement the verification, we need to:

  1. Include the Instruction Sysvar program (Sysvar1nstructions1111111111111111111111111, which we'll refer to as instructions)
  2. Place the Secp256r1 instruction after our current instruction

Here's how to access and deserialize the instructions:

// Deserialize the instructions sysvar
let instructions: Instructions<Ref<[u8]>> = Instructions::try_from(self.accounts.instructions)?;
// Get the instruction that follows our current one
let ix: IntrospectedInstruction = instructions.get_instruction_relative(1)?;

Next, we deserialize the Secp256r1 instruction:

// Deserialize the Secp256r1 instruction
let secp256r1_ix = Secp256r1Instruction::try_from(&ix)?;

We then perform some security checks.

It's crucial to implement several security checks:

  1. Authority Check: Ensures that only authorized recipients can receive funds from the PDA that wraps the Secp256r1 public key. This prevents MEV attacks where someone could intercept the transaction, capture the valid signature, and replace the intended recipient.
  2. Expiry Check: Enforces a time limit on signature validity. Since validated signatures remain valid indefinitely, implementing an expiry timestamp prevents replay attacks.

We perform these checks by putting this data in the message of the signature.

Here's how to implement these security checks:

// Verify the fee payer is authorized
let (receiver, expiry) = secp256r1_ix.get_message_data(0)?.split_at_checked(32).ok_or(ProgramError::InvalidInstructionData)?;
if self.accounts.payer.key().ne(payer) {
    return Err(ProgramError::InvalidAccountOwner);
}
 
// Check signature expiration
let now = Clock::get()?.unix_timestamp;
let expiry = i64::from_le_bytes(expiry.try_into().map_err(|_| ProgramError::InvalidInstructionData)?);
if now > expiry {
    return Err(ProgramError::InvalidInstructionData);
}

Finally, we can derive Program Derived Addresses (PDAs) directly from Secp256r1 public keys, creating deterministic account addresses that users can control through modern authentication methods:

// Verify the first signature matches our PDA owner
let signer: Secp256r1Pubkey = *secp256r1_ix.get_signer(0)?;
// Create signer seeds for CPI
let seeds = [
    Seed::from(signer[..1].as_ref()),
    Seed::from(signer[1..].as_ref()),
];
let signers = [Signer::from(&seeds)];

We need to split the pubkey in 2 different parts because seeds accept 32 bytes as maximum length.

Contents
View Source
Blueshift © 2025Commit: cc5f933