siwx-rs

Crates.iosiwx-rs
lib.rssiwx-rs
version0.2.2
created_at2025-10-29 14:27:44.566684+00
updated_at2025-10-29 14:27:44.566684+00
descriptionMulti-chain Sign-In with X (SIWX) library supporting Ethereum and Solana
homepage
repositoryhttps://github.com/zyphelabs/siwx-rs
max_upload_size
id1906679
size310,235
Manuel Tumiati (iltumio)

documentation

README

SIWX-RS: Multi-Chain Sign-In with X Library

A Rust library for implementing Sign-In with X (SIWX) authentication across multiple blockchain networks, following the EIP-4361 standard.

Features

  • Multi-chain support: Ethereum and Solana (extensible to other chains)
  • EIP-4361 compliance: Standard message format for authentication
  • Smart contract wallet support: Designed for EOA and contract wallets
  • Backend agnostic: Use any blockchain library (ethers-rs, alloy-rs, etc.)
  • Flexible signature verification: Support for different signature formats
  • Public key abstraction: Trait-based design for extensible public key support
  • Async/await support: Modern Rust async patterns
  • Comprehensive validation: Message, signature, and public key validation
  • Extensible architecture: Easy to add new chains, signature types, and public key formats

Supported Chains

  • Ethereum (Mainnet & Testnets) [enabled by default; no action required]
    • EIP-191 personal_sign signatures
    • EIP-1271 smart contract signatures
    • secp256k1 cryptography
  • Solana (Mainnet & Testnets) [requires enabling the solana feature]
    • Ed25519 signatures
    • Base58 encoding

Installation

Add to your Cargo.toml:

[dependencies]
siwx-rs = { version = "0.1.0", features = ["full"] }

Feature Flags

  • default: Core + Ethereum (enabled by default)
  • ethereum: Ethereum-specific dependencies (Alloy meta crate). Enabled by default.
  • solana: Solana-specific dependencies (solana-sdk, bs58, ed25519-dalek)
  • full: All features enabled (Ethereum + Solana)

Quick Start

Basic Usage

use siwx_rs::prelude::*;

#[tokio::main]
async fn main() -> SiwxResult<()> {
    // Create a SIWX message for Ethereum
    let message = SiwxMessage::new_with_current_time(
        "example.com",
        "0x1234567890123456789012345678901234567890",
        "https://example.com/login",
        "1",
        SiwxMessage::generate_nonce(),
    )
    .with_statement("Sign in to Example App")
    .with_expiration_time(
        (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
    );

    // Get the message to sign
    let message_to_sign = message.message_to_sign()?;
    println!("Message to sign:\n{}", message_to_sign);

    // Create a verifier (Ethereum is enabled by default)
    let verifier = SignatureVerifier::new(Chain::Ethereum)
        .with_backend(Box::new(EthereumSecp256k1Verifier::new(std::env::var("ETHEREUM_RPC_URL").ok())));

    // Verify a signature (example). For Ethereum EIP-191, provide the signer address.
    // You may pass either an Ethereum address (recommended) or an uncompressed
    // secp256k1 public key (65 bytes, 0x04-prefixed) as the "public key" parameter.
    // Verification is address-based and recovers the signer from the signature.
    let signature = Signature::eip191(
        "0x<65-byte-signature-hex-rsv>",
        "0x1234567890123456789012345678901234567890",
    );

    // For Ethereum EIP-191, verification recovers the signer from the signature,
    // so no `PublicKey` needs to be provided to the verifier in this example.
    let is_valid = verifier.verify(&message, &signature).await?;
    println!("Signature valid: {}", is_valid);

    Ok(())
}

Parsing plaintext EIP-4361 messages

use siwx_rs::prelude::*;
use std::str::FromStr;

let plaintext = r#"example.com wants you to sign in with your Ethereum account:
0x1234567890123456789012345678901234567890

Sign in to Example App

URI: https://example.com/login
Version: 1
Chain ID: 1
Nonce: 12345678-1234-1234-1234-123456789012
Issued At: 2024-01-01T00:00:00Z
"#;

let msg = SiwxMessage::from_str(plaintext)?; // or: let msg: SiwxMessage = plaintext.parse()?;
msg.validate()?; // optional

Ethereum Example

use siwx_rs::prelude::*;

// Ethereum is enabled by default (no feature flag required)
// Create Ethereum SIWX message
let eth_message = SiwxMessage::new_with_chain(
    "example.com",
    "0x1234567890123456789012345678901234567890",
    "https://example.com/login",
    "1",
    chrono::Utc::now().to_rfc3339(),
    SiwxMessage::generate_nonce(),
    Chain::Ethereum,
)
.with_statement("Sign in to Example App")
.with_expiration_time(
    (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
);

// Get EIP-191 formatted message
let message_to_sign = eth_message.message_to_sign()?;

// Create verifier with default backend (supports EIP-191 and EIP-1271)
let verifier = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(EthereumSecp256k1Verifier::new(std::env::var("ETHEREUM_RPC_URL").ok())));
// EIP-191 address-only flow: pass the address as the key
let addr_key = PublicKeyFactory::for_chain(
    "0x1234567890123456789012345678901234567890",
    Chain::Ethereum,
)?;

Solana Example

use siwx_rs::prelude::*;

// Requires `--features solana`
// Create Solana SIWX message
let sol_message = SiwxMessage::new_with_chain(
    "example.com",
    "11111111111111111111111111111112",
    "https://example.com/login",
    "1",
    chrono::Utc::now().to_rfc3339(),
    SiwxMessage::generate_nonce(),
    Chain::Solana,
)
.with_statement("Sign in to Example App");

// Get Solana formatted message
let message_to_sign = sol_message.message_to_sign()?;

// Create verifier with default backend
let verifier = SignatureVerifier::new(Chain::Solana)
    .with_backend(Box::new(SolanaEd25519Verifier));

Solana Smart Accounts (PDAs) and Squads Compatibility

The Solana backend supports smart accounts implemented as Program Derived Accounts (PDAs). Since PDAs cannot sign, SIWX must be signed by an authority key associated with the PDA. The verifier then:

  • Validates the SIWX message address equals the PDA derived from the provided seeds and program id
  • Verifies the Ed25519 signature against the authority public key

To use this flow, provide the following in the Signature metadata:

  • program_id: the program id that owns the PDA (base58 string)
  • pda_seeds: JSON array of base58-encoded seed byte arrays used to derive the PDA (e.g., ["<SEED1_BASE58>", "<SEED2_BASE58>"]). Do not use base64. If you have raw bytes, encode each with base58 (e.g., bs58::encode(&seed_bytes).into_string()).

Example using an authority key for a PDA:

use siwx_rs::prelude::*;

// Requires `--features solana`
// Assume you already know the PDA and its program id/seeds used to derive it
let program_id_b58 = "<PROGRAM_ID_BASE58>";
let pda_address_b58 = "<PDA_ADDRESS_BASE58>";
let pda_seeds_json = serde_json::json!(["<SEED1_BASE58>", "<SEED2_BASE58>"]).to_string();

// Build SIWX message addressed to the PDA
let message = SiwxMessage::new_with_chain(
    "example.com",
    pda_address_b58.to_string(),
    "https://example.com/login",
    "1",
    chrono::Utc::now().to_rfc3339(),
    SiwxMessage::generate_nonce(),
    Chain::Solana,
);

// Authority signs the message (ed25519). `authority_pubkey_b58` is base58 of the authority key
let sig_b58 = "<AUTHORITY_SIGNATURE_BASE58>";
let authority_pubkey_b58 = "<AUTHORITY_PUBKEY_BASE58>";

let signature = Signature::ed25519(sig_b58, authority_pubkey_b58)
    .with_metadata("program_id", program_id_b58.to_string())
    .with_metadata("pda_seeds", pda_seeds_json);

let public_key = PublicKeyFactory::solana(pda_address_b58);
let verifier = SignatureVerifier::new(Chain::Solana)
    .with_backend(Box::new(SolanaEd25519Verifier));
let is_valid = verifier.verify(&message, &signature).await?;

Squads (SquadsX) vaults are PDAs. This flow is compatible with Squads as long as you use an authority key (e.g., a member key or relayer key) to sign off-chain and pass the correct program_id and pda_seeds. The verifier will confirm the PDA derivation and the authority signature.

Notes:

  • This library does not (yet) enforce on-chain multisig policy (e.g., membership/threshold checks) for Squads. If you need that, you can layer an optional RPC-backed check to validate that the authority is authorized for the given vault before accepting the signature.
  • PDA derivation uses solana_sdk::Pubkey::find_program_address with the provided seeds; no RPC calls are made during verification.
  • For more about Squads, see the official docs: Squads Protocol documentation.

Example: derive a Squads PDA and build metadata

use bs58;
use hex;
use siwx_rs::prelude::*;
use solana_sdk::pubkey::Pubkey;
use std::str::FromStr;

// Replace with the real Squads v4 Program ID
let program_id = Pubkey::from_str("<SQUADS_V4_PROGRAM_ID>").unwrap();

// Replace with actual Squads seeds per their documentation.
// Here we show two generic seed buffers as an example.
let seed1: Vec<u8> = b"multisig".to_vec();
let seed2: Vec<u8> = hex::decode("<MULTISIG_ID_HEX>").unwrap();

// Derive the PDA address
let (pda, _bump) = Pubkey::find_program_address(&[&seed1, &seed2], &program_id);
let pda_address_b58 = pda.to_string();

// Prepare pda_seeds metadata as base58-encoded seed buffers
let pda_seeds_json = serde_json::json!([
    bs58::encode(&seed1).into_string(),
    bs58::encode(&seed2).into_string(),
])
.to_string();

// Build the SIWX message addressed to the PDA
let message = SiwxMessage::new_with_chain(
    "example.com",
    pda_address_b58.clone(),
    "https://example.com/login",
    "1",
    chrono::Utc::now().to_rfc3339(),
    SiwxMessage::generate_nonce(),
    Chain::Solana,
);

// Authority signs the message off-chain (produce sig_b58, authority_pubkey_b58)
let signature = Signature::ed25519(sig_b58, authority_pubkey_b58)
    .with_metadata("program_id", program_id.to_string())
    .with_metadata("pda_seeds", pda_seeds_json);

let public_key = PublicKeyFactory::solana(pda_address_b58);
let verifier = SignatureVerifier::new(Chain::Solana)
    .with_backend(Box::new(SolanaEd25519Verifier));
let is_valid = verifier.verify(&message, &signature).await?;

Message Format

The library generates messages following the EIP-4361 standard:

Ethereum Format

example.com wants you to sign in with your Ethereum account:
0x1234567890123456789012345678901234567890

Sign in to Example App

URI: https://example.com/login
Version: 1
Chain ID: 1
Nonce: 12345678-1234-1234-1234-123456789012
Issued At: 2024-01-01T00:00:00Z
Expiration Time: 2024-01-01T01:00:00Z

Solana Format

example.com wants you to sign in with your Solana account:
11111111111111111111111111111112

Sign in to Example App

URI: https://example.com/login
Version: 1
Chain ID: 101
Nonce: 12345678-1234-1234-1234-123456789012
Issued At: 2024-01-01T00:00:00Z

Public Key Abstraction

The library provides a trait-based abstraction for public keys, making it easy to support different blockchain-specific public key formats and add new ones in the future.

Using Public Keys

use siwx_rs::prelude::*;

// Create Ethereum public key (uncompressed 65-byte secp256k1, 0x04 + 64 bytes)
// Ethereum is enabled by default (no feature flag required)
let eth_public_key = PublicKeyFactory::for_chain(
    "0x04<128-hex-chars-of-uncompressed-pubkey>",
    Chain::Ethereum,
);

// Create Solana public key
let sol_public_key = PublicKeyFactory::solana("11111111111111111111111111111112");

// Auto-detect public key type (requires the relevant feature for detected chain)
let auto_detected = PublicKeyFactory::auto_detect("0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890")?;

// Chain-specific creation
let eth_for_chain = PublicKeyFactory::for_chain(
    "0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
    Chain::Ethereum
)?;

Public Key Validation

// Validate public key format
eth_public_key.validate()?;

// Check signature type support
if eth_public_key.supports_signature_type(&SignatureType::Eip191) {
    println!("Supports EIP-191 signatures");
}

// Get address from public key
let address = eth_public_key.address()?;

Extending for New Chains

To add support for a new blockchain, implement the PublicKey trait:

use siwx_rs::{PublicKey, Chain, SiwxError, SiwxResult, SignatureType};

struct BitcoinPublicKey {
    key: String,
}

impl PublicKey for BitcoinPublicKey {
    fn chain(&self) -> Chain {
        Chain::Bitcoin // You'd need to add this to the Chain enum
    }

    fn as_string(&self) -> String {
        self.key.clone()
    }

    fn as_bytes(&self) -> SiwxResult<Vec<u8>> {
        // Implement Bitcoin-specific decoding
        Ok(vec![])
    }

    fn validate(&self) -> SiwxResult<()> {
        // Implement Bitcoin-specific validation
        Ok(())
    }

    fn address(&self) -> SiwxResult<String> {
        // Implement Bitcoin address derivation
        Ok(self.key.clone())
    }

    fn supports_signature_type(&self, signature_type: &SignatureType) -> bool {
        matches!(signature_type, SignatureType::Bitcoin) // You'd need to add this
    }

    fn key_type(&self) -> &'static str {
        "secp256k1"
    }
}

Signature Verification

Using Default Backends

// Create verifier with default backend (Ethereum supports EIP-191 and EIP-1271)
let verifier = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(EthereumSecp256k1Verifier::new(std::env::var("ETHEREUM_RPC_URL").ok())));

// Verify signature (Ethereum supports passing an address or uncompressed secp256k1 pubkey)
// Address-only recommended:
let public_key = PublicKeyFactory::for_chain(
    "0x1234567890123456789012345678901234567890",
    Chain::Ethereum,
)?;
let is_valid = verifier.verify(&message, &signature).await?;

EthereumAutodetect signature type

When you don't know upfront whether the signer is an EOA (EIP-191) or a smart contract wallet (EIP-1271), use EthereumAutodetect. The verifier will:

  • Route to EIP-191 if signature.signer equals SiwxMessage.address (case-insensitive)
  • Otherwise, route to EIP-1271 and call isValidSignature on signature.signer
use siwx_rs::prelude::*;

// Build a standard SIWX message
let message = SiwxMessage::new(
    "example.com",
    "0x1234567890123456789012345678901234567890",
    "https://example.com/login",
    "1",
    "2024-01-01T00:00:00Z",
    "nonce123",
);

let signature = Signature::ethereum_autodetect(
    "0x<hex-signature>"
).with_signer("0x1234567890123456789012345678901234567890"); // signer (EOA or contract)

// Provide the account key (address is recommended for Ethereum)
let key = PublicKeyFactory::for_chain(
    "0x1234567890123456789012345678901234567890",
    Chain::Ethereum,
)?;

let verifier = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(EthereumSecp256k1Verifier::new(std::env::var("ETHEREUM_RPC_URL").ok())));
let ok = verifier.verify(&message, &signature).await?;

Ethereum Backend Configuration

  • The default Ethereum backend supports:
    • EIP-191 (personal_sign) with 65-byte signatures (r|s|v). The verifier recovers the signer address from the signature and compares it to message.address/signature.signer.
    • EIP-1271 (smart contract validation) by calling isValidSignature via RPC.
  • RPC URL
    • Pass an optional RPC URL to new(...). Use Some(url) to set a provider; use None if you don't need RPC (EIP-191 only).
    • There is no built-in environment-variable fallback; you can pass std::env::var("ETHEREUM_RPC_URL").ok() yourself.

Construct the verifier (feature ethereum must be enabled):

use siwx_rs::prelude::*;
#[cfg(feature = "ethereum")]
use siwx_rs::backend::ethereum::EthereumSecp256k1Verifier;

// Pass None when you don't need RPC (EIP-191). EIP-1271 requires an RPC URL.
let verifier = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(EthereumSecp256k1Verifier::new(None)));

// Or provide your own provider URL
let verifier_custom = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(EthereumSecp256k1Verifier::new(Some("https://mainnet.infura.io/v3/<KEY>".to_string()))));

Custom Backend Implementation

use async_trait::async_trait;

struct CustomEthereumBackend;

#[async_trait]
impl SignatureVerifierBackend for CustomEthereumBackend {
    async fn verify(
        &self,
        message: &SiwxMessage,
        signature: &Signature,
    ) -> SiwxResult<bool> {
        // Your custom verification logic here
        // You can use ethers-rs, alloy-rs, or any other library
        Ok(true)
    }

    fn supported_chain(&self) -> Chain {
        Chain::Ethereum
    }

    fn supported_signature_types(&self) -> Vec<SignatureType> { vec![SignatureType::Eip191, SignatureType::Eip1271] }
}

// Use custom backend
let verifier = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(CustomEthereumBackend));

Signer vs Public Key (why both?)

  • Signature.signer (authority): who produced the signature. Attach it with Signature::with_signer(...). On Ethereum this may be an EOA or smart contract wallet address (EIP-1271). On Solana this is the authority key.
  • Account (from SiwxMessage.address): the account being authenticated. Set via SiwxMessage.address (e.g., Ethereum address or Solana account/PDA). This is not passed separately to verify().

You may use PublicKeyFactory in your application to parse/validate addresses or keys, but it is not passed to verify().

Backend behavior and checks:

  • Ethereum EIP-191

    • Recover the signer address from the signature and require it equals message.address.
    • If signature.signer is provided, it must also equal message.address.
  • Ethereum EIP-1271

    • signature.signer is the contract address and must equal message.address.
    • Verifier calls isValidSignature on that contract; no public key is provided to verification.
  • Solana Ed25519 (EOA and PDA)

    • EOA flow: if signature.signer equals message.address, verify directly against that authority key.
    • PDA flow: provide program_id and pda_seeds in signature.metadata. The verifier derives the PDA and requires it equals message.address, then verifies the signature with the authority in signature.signer.

This separation enables EOAs and smart accounts while preventing cross-account replay and signer/account mismatches.

Smart Contract Wallet Support (EIP-1271)

The default Ethereum backend validates EIP-1271 signatures by calling isValidSignature on the contract specified by signature.signer. Requirements:

  • message.address must equal the contract address (prevents cross-contract replay).
  • signature.signer must be the contract address.
  • signature.signature must be a 0x-prefixed even-length hex string (arbitrary length per contract).
  • No public key is required for EIP-1271; the provided "public key" parameter is ignored by the verifier.

Example:

use siwx_rs::prelude::*;

let message = SiwxMessage::new(
    "example.com",
    "0xContractAddress...", // same as the contract address below
    "https://example.com/login",
    "1",
    "2024-01-01T00:00:00Z",
    "nonce123",
);

let signature = Signature::eip1271(
    "0x<contract-defined-signature-hex>",
    "0xContractAddress...",
);

// You may pass an address as the key; it is not used by EIP-1271 verification
let dummy_key = PublicKeyFactory::ethereum(contract_address)?;
let verifier = SignatureVerifier::new(Chain::Ethereum)
    .with_backend(Box::new(EthereumSecp256k1Verifier::new(std::env::var("ETHEREUM_RPC_URL").ok())));
let ok = verifier.verify(&message, &signature).await?;

Message Validation

The library provides comprehensive message validation:

// Validate message format
message.validate()?;

// Check if message has expired
if message.is_expired()? {
    return Err(SiwxError::MessageExpired);
}

// Check if message is valid for signing
if !message.is_valid_for_signing()? {
    return Err(SiwxError::InvalidMessageFormat("Message not yet valid".into()));
}

Error Handling

The library uses custom error types for better error handling:

use siwx_rs::SiwxError;

match result {
    Ok(_) => println!("Success"),
    Err(SiwxError::MessageExpired) => println!("Message has expired"),
    Err(SiwxError::InvalidSignature(msg)) => println!("Invalid signature: {}", msg),
    Err(SiwxError::VerificationFailed(msg)) => println!("Verification failed: {}", msg),
    Err(e) => println!("Other error: {}", e),
}

Examples

Run the examples:

# Basic usage
cargo run --example basic_usage

# Public key abstraction example
cargo run --example public_key_usage

# With specific features
cargo run --example basic_usage --features ethereum
cargo run --example basic_usage --features solana

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Add tests
  5. Submit a pull request

License

Apache 2.0 License - see LICENSE file for details.

Roadmap

  • Support for more chains (Polygon, BSC, etc.)
  • More signature types
  • Web3 integration examples
  • Performance optimizations
  • More comprehensive documentation
  • CLI tool for message generation and verification
Commit count: 0

cargo fmt