use ed25519_dalek::{Signer as _, SigningKey};
use sha2::{Digest, Sha256};
use super::Signer;
use crate::errors::SdkError;
const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const ASSOCIATED_TOKEN_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
const COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111";
const MEMO_PROGRAM_ID: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
const TRANSFER_CHECKED: u8 = 12;
const SET_COMPUTE_UNIT_LIMIT: u8 = 2;
const SET_COMPUTE_UNIT_PRICE: u8 = 3;
const DEFAULT_COMPUTE_UNIT_LIMIT: u32 = 20_000;
const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS: u64 = 1;
pub(crate) const MAX_MEMO_BYTES: usize = 256;
const V0_MESSAGE_PREFIX: u8 = 0x80;
#[derive(Debug, Clone)]
pub struct SvmTransferRequest {
pub mint: String,
pub pay_to: String,
pub fee_payer: String,
pub amount: u64,
pub decimals: u8,
pub recent_blockhash: String,
pub token_program: String,
pub memo: String,
}
impl Signer {
pub(super) fn svm_address(&self) -> Result<String, SdkError> {
let key = svm_signing_key(self)?;
Ok(bs58::encode(key.verifying_key().to_bytes()).into_string())
}
pub fn sign_svm_transfer(&self, req: &SvmTransferRequest) -> Result<Vec<u8>, SdkError> {
let key = svm_signing_key(self)?;
let payer = key.verifying_key().to_bytes();
if req.token_program != TOKEN_PROGRAM_ID && req.token_program != TOKEN_2022_PROGRAM_ID {
return Err(SdkError::Config(format!(
"mint {} is owned by {}, which is not a known SPL token program",
req.mint, req.token_program
)));
}
let token_program = decode_pubkey(&req.token_program)?;
let mint = decode_pubkey(&req.mint)?;
let pay_to_owner = decode_pubkey(&req.pay_to)?;
let fee_payer = decode_pubkey(&req.fee_payer)?;
let compute_budget = decode_pubkey(COMPUTE_BUDGET_PROGRAM_ID)?;
let memo_program = decode_pubkey(MEMO_PROGRAM_ID)?;
let memo_data = req.memo.as_bytes();
if memo_data.len() > MAX_MEMO_BYTES {
return Err(SdkError::Config(format!(
"x402 Solana memo exceeds the maximum {MAX_MEMO_BYTES} bytes"
)));
}
let source_ata = associated_token_address(&payer, &token_program, &mint)?;
let dest_ata = associated_token_address(&pay_to_owner, &token_program, &mint)?;
let accounts = vec![
fee_payer,
payer,
source_ata,
dest_ata,
mint,
token_program,
compute_budget,
memo_program,
];
let header = MessageHeader {
num_required_signatures: 2,
num_readonly_signed: 0,
num_readonly_unsigned: 4,
};
let index =
|pk: &[u8; 32]| -> u8 { accounts.iter().position(|a| a == pk).map_or(0, |p| p as u8) };
let mut cu_limit_data = Vec::with_capacity(5);
cu_limit_data.push(SET_COMPUTE_UNIT_LIMIT);
cu_limit_data.extend_from_slice(&DEFAULT_COMPUTE_UNIT_LIMIT.to_le_bytes());
let mut cu_price_data = Vec::with_capacity(9);
cu_price_data.push(SET_COMPUTE_UNIT_PRICE);
cu_price_data.extend_from_slice(&DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS.to_le_bytes());
let mut transfer_data = Vec::with_capacity(10);
transfer_data.push(TRANSFER_CHECKED);
transfer_data.extend_from_slice(&req.amount.to_le_bytes());
transfer_data.push(req.decimals);
let instructions = vec![
Instruction {
program_index: index(&compute_budget),
account_indexes: Vec::new(),
data: cu_limit_data,
},
Instruction {
program_index: index(&compute_budget),
account_indexes: Vec::new(),
data: cu_price_data,
},
Instruction {
program_index: index(&token_program),
account_indexes: vec![
index(&source_ata),
index(&mint),
index(&dest_ata),
index(&payer),
],
data: transfer_data,
},
Instruction {
program_index: index(&memo_program),
account_indexes: Vec::new(),
data: memo_data.to_vec(),
},
];
let message = build_message(&header, &accounts, &req.recent_blockhash, &instructions)?;
let payer_sig = key.sign(&message).to_bytes();
let mut tx = Vec::new();
write_compact_u16(&mut tx, 2);
tx.extend_from_slice(&[0u8; 64]); tx.extend_from_slice(&payer_sig); tx.extend_from_slice(&message);
Ok(tx)
}
}
pub(super) fn sign_siws(signer: &Signer, message: &str) -> Result<String, SdkError> {
let key = svm_signing_key(signer)?;
let signature = key.sign(message.as_bytes());
Ok(bs58::encode(signature.to_bytes()).into_string())
}
struct MessageHeader {
num_required_signatures: u8,
num_readonly_signed: u8,
num_readonly_unsigned: u8,
}
struct Instruction {
program_index: u8,
account_indexes: Vec<u8>,
data: Vec<u8>,
}
pub(super) fn generate_svm_key() -> String {
use rand::RngCore;
let mut seed = [0u8; 32];
rand::thread_rng().fill_bytes(&mut seed);
let key = SigningKey::from_bytes(&seed);
let mut full = Vec::with_capacity(64);
full.extend_from_slice(&seed);
full.extend_from_slice(&key.verifying_key().to_bytes());
bs58::encode(full).into_string()
}
fn svm_signing_key(signer: &Signer) -> Result<SigningKey, SdkError> {
let Signer::Svm(secret) = signer else {
return Err(SdkError::Config(
"sign_svm_transfer requires an Svm signer".into(),
));
};
use secrecy::ExposeSecret;
let raw = secret.expose_secret();
let bytes = bs58::decode(raw.trim())
.into_vec()
.map_err(|_| SdkError::Config("Solana key is not valid base58".into()))?;
let seed: [u8; 32] = bytes
.get(..32)
.and_then(|s| s.try_into().ok())
.ok_or_else(|| SdkError::Config("Solana key must be at least 32 bytes".into()))?;
Ok(SigningKey::from_bytes(&seed))
}
fn decode_pubkey(b58: &str) -> Result<[u8; 32], SdkError> {
let bytes = bs58::decode(b58)
.into_vec()
.map_err(|_| SdkError::Config(format!("invalid base58 pubkey: {b58}")))?;
bytes
.try_into()
.map_err(|_| SdkError::Config(format!("pubkey must be 32 bytes: {b58}")))
}
fn associated_token_address(
owner: &[u8; 32],
token_program: &[u8; 32],
mint: &[u8; 32],
) -> Result<[u8; 32], SdkError> {
let ata_program = decode_pubkey(ASSOCIATED_TOKEN_PROGRAM_ID)?;
for bump in (0u8..=255).rev() {
let mut hasher = Sha256::new();
hasher.update(owner);
hasher.update(token_program);
hasher.update(mint);
hasher.update([bump]);
hasher.update(ata_program);
hasher.update(b"ProgramDerivedAddress");
let candidate: [u8; 32] = hasher.finalize().into();
if !is_on_curve(&candidate) {
return Ok(candidate);
}
}
Err(SdkError::Config(
"could not derive associated token account (no off-curve bump)".into(),
))
}
fn is_on_curve(bytes: &[u8; 32]) -> bool {
ed25519_dalek::VerifyingKey::from_bytes(bytes).is_ok()
}
fn build_message(
header: &MessageHeader,
accounts: &[[u8; 32]],
recent_blockhash: &str,
instructions: &[Instruction],
) -> Result<Vec<u8>, SdkError> {
let blockhash = decode_pubkey(recent_blockhash)?;
let mut msg = vec![
V0_MESSAGE_PREFIX,
header.num_required_signatures,
header.num_readonly_signed,
header.num_readonly_unsigned,
];
write_compact_u16(&mut msg, accounts.len() as u16);
for acct in accounts {
msg.extend_from_slice(acct);
}
msg.extend_from_slice(&blockhash);
write_compact_u16(&mut msg, instructions.len() as u16);
for ix in instructions {
msg.push(ix.program_index);
write_compact_u16(&mut msg, ix.account_indexes.len() as u16);
msg.extend_from_slice(&ix.account_indexes);
write_compact_u16(&mut msg, ix.data.len() as u16);
msg.extend_from_slice(&ix.data);
}
write_compact_u16(&mut msg, 0);
Ok(msg)
}
fn write_compact_u16(out: &mut Vec<u8>, mut value: u16) {
loop {
let mut byte = (value & 0x7f) as u8;
value >>= 7;
if value != 0 {
byte |= 0x80;
}
out.push(byte);
if value == 0 {
break;
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn throwaway_signer() -> Signer {
let seed = [1u8; 32];
let key = SigningKey::from_bytes(&seed);
let mut full = Vec::with_capacity(64);
full.extend_from_slice(&seed);
full.extend_from_slice(&key.verifying_key().to_bytes());
Signer::Svm(bs58::encode(full).into_string().into())
}
#[test]
fn compact_u16_encoding() {
let mut buf = Vec::new();
write_compact_u16(&mut buf, 1);
assert_eq!(buf, vec![1]);
buf.clear();
write_compact_u16(&mut buf, 128);
assert_eq!(buf, vec![0x80, 0x01]);
}
#[test]
fn svm_address_is_base58_pubkey() {
let signer = throwaway_signer();
let addr = signer.svm_address().unwrap();
assert!(addr.len() >= 43 && addr.len() <= 44, "addr: {addr}");
assert!(bs58::decode(&addr).into_vec().unwrap().len() == 32);
}
#[test]
fn ata_is_deterministic_and_off_curve() {
let owner = [2u8; 32];
let token_program = decode_pubkey(TOKEN_PROGRAM_ID).unwrap();
let mint = [3u8; 32];
let a = associated_token_address(&owner, &token_program, &mint).unwrap();
let b = associated_token_address(&owner, &token_program, &mint).unwrap();
assert_eq!(a, b);
assert!(!is_on_curve(&a));
}
fn test_request() -> SvmTransferRequest {
SvmTransferRequest {
mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(),
pay_to: bs58::encode([4u8; 32]).into_string(),
fee_payer: bs58::encode([5u8; 32]).into_string(),
amount: 1000,
decimals: 6,
recent_blockhash: bs58::encode([6u8; 32]).into_string(),
token_program: TOKEN_PROGRAM_ID.into(),
memo: "0123456789abcdef".into(),
}
}
#[test]
fn transfer_produces_two_sig_slots_with_payer_filled() {
let signer = throwaway_signer();
let tx = signer.sign_svm_transfer(&test_request()).unwrap();
assert_eq!(tx[0], 2);
assert_eq!(&tx[1..65], &[0u8; 64]);
assert!(tx[65..129].iter().any(|&b| b != 0));
}
#[test]
fn message_is_v0_with_four_instructions() {
let signer = throwaway_signer();
let tx = signer.sign_svm_transfer(&test_request()).unwrap();
let msg = &tx[129..];
assert_eq!(msg[0], V0_MESSAGE_PREFIX, "v0 version prefix");
assert_eq!(&msg[1..4], &[2, 0, 4], "header: 2 signers, 4 readonly");
assert_eq!(msg[4], 8, "account count");
let after_accounts = 5 + 8 * 32;
let after_blockhash = after_accounts + 32;
assert_eq!(msg[after_blockhash], 4, "four instructions");
let mut cursor = after_blockhash + 1;
let mut seen = Vec::new();
for _ in 0..4 {
let program_index = msg[cursor];
cursor += 1;
let n_accounts = msg[cursor] as usize;
cursor += 1 + n_accounts;
let n_data = msg[cursor] as usize;
cursor += 1;
seen.push((program_index, msg[cursor], n_accounts));
cursor += n_data;
}
assert_eq!(
seen,
vec![
(6, SET_COMPUTE_UNIT_LIMIT, 0),
(6, SET_COMPUTE_UNIT_PRICE, 0),
(5, TRANSFER_CHECKED, 4),
(7, b'0', 0),
]
);
assert_eq!(msg[cursor], 0, "no address table lookups");
assert_eq!(cursor + 1, msg.len(), "message fully consumed");
}
#[test]
fn oversized_memo_is_rejected() {
let signer = throwaway_signer();
let req = SvmTransferRequest {
memo: "x".repeat(MAX_MEMO_BYTES + 1),
..test_request()
};
let err = signer.sign_svm_transfer(&req).unwrap_err();
assert!(
matches!(err, SdkError::Config(msg) if msg.contains("memo")),
"expected a memo-size Config error"
);
}
}