use anyhow::{bail, Result};
use base64::Engine;
use solana_sdk::{pubkey::Pubkey, transaction::Transaction};
use spl_associated_token_account::get_associated_token_address;
use std::str::FromStr;
use tracing::debug;
use crate::types::{PaymentRequirements, SvmExactPayload, VerifyResponse};
pub fn verify(payload: &SvmExactPayload, requirements: &PaymentRequirements) -> VerifyResponse {
match verify_inner(payload, requirements) {
Ok(payer) => VerifyResponse {
is_valid: true,
invalid_reason: None,
payer: Some(payer),
},
Err(e) => VerifyResponse {
is_valid: false,
invalid_reason: Some(e.to_string()),
payer: None,
},
}
}
fn verify_inner(payload: &SvmExactPayload, requirements: &PaymentRequirements) -> Result<String> {
let tx_bytes = base64::engine::general_purpose::STANDARD
.decode(&payload.transaction)
.map_err(|_| anyhow::anyhow!("Invalid base64 encoding on transaction"))?;
let tx: Transaction = bincode::deserialize(&tx_bytes)
.map_err(|_| anyhow::anyhow!("Failed to deserialize Solana transaction"))?;
let pay_to = Pubkey::from_str(&requirements.pay_to)
.map_err(|_| anyhow::anyhow!("Invalid pay_to address: {}", requirements.pay_to))?;
let asset_mint = Pubkey::from_str(&requirements.asset)
.map_err(|_| anyhow::anyhow!("Invalid asset mint: {}", requirements.asset))?;
let n = tx.message.instructions.len();
if !(3..=6).contains(&n) {
bail!("Expected 3-6 instructions, got {}", n);
}
let token_prog = spl_token::id();
let mut transfer_validated = false;
let mut payer_key: Option<Pubkey> = None;
for ix in &tx.message.instructions {
let prog_idx = ix.program_id_index as usize;
if prog_idx >= tx.message.account_keys.len() {
continue;
}
if tx.message.account_keys[prog_idx] != token_prog {
continue;
}
let token_ix = match spl_token::instruction::TokenInstruction::unpack(&ix.data) {
Ok(i) => i,
Err(_) => continue,
};
if let spl_token::instruction::TokenInstruction::TransferChecked { amount, .. } = token_ix {
if ix.accounts.len() < 4 {
bail!("TransferChecked instruction has fewer than 4 accounts");
}
let mint_idx = ix.accounts[1] as usize;
let dest_idx = ix.accounts[2] as usize;
let auth_idx = ix.accounts[3] as usize;
let keys = &tx.message.account_keys;
if mint_idx >= keys.len() || dest_idx >= keys.len() || auth_idx >= keys.len() {
bail!("TransferChecked account index out of range");
}
if keys[mint_idx] != asset_mint {
bail!(
"Transfer mint {} != required asset {}",
keys[mint_idx],
asset_mint
);
}
let expected_dest = get_associated_token_address(&pay_to, &asset_mint);
if keys[dest_idx] != expected_dest {
bail!(
"Transfer destination {} != expected ATA {} for payTo={}",
keys[dest_idx],
expected_dest,
pay_to
);
}
if amount != requirements.amount {
bail!(
"Transfer amount {} != required amount {}",
amount,
requirements.amount
);
}
payer_key = Some(keys[auth_idx]);
transfer_validated = true;
debug!(
dest = %keys[dest_idx],
amount,
"TransferChecked validated"
);
break;
}
}
if !transfer_validated {
bail!("No valid SPL TransferChecked instruction found in transaction");
}
let payer = payer_key.ok_or_else(|| anyhow::anyhow!("Could not identify payer"))?;
if let Some(idx) = tx.message.account_keys.iter().position(|k| k == &payer) {
if idx < tx.signatures.len() {
if tx.signatures[idx] == solana_sdk::signature::Signature::default() {
bail!("Transaction is not signed by the payer authority");
}
} else {
bail!(
"Payer account index {} has no corresponding signature slot",
idx
);
}
}
Ok(payer.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::build_payment_payload;
use solana_sdk::{
compute_budget::ComputeBudgetInstruction, message::Message, signature::Keypair,
signer::Signer,
};
use spl_associated_token_account::get_associated_token_address;
use spl_token::instruction::transfer_checked;
const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const DECIMALS: u8 = 6;
fn reqs(asset: &str, pay_to: &str, amount: u64) -> PaymentRequirements {
PaymentRequirements {
scheme: "exact".into(),
network: "solana-mainnet".into(),
asset: asset.into(),
amount,
pay_to: pay_to.into(),
max_timeout_seconds: 120,
extra: serde_json::Value::Null,
}
}
#[test]
fn valid_payload_verifies_and_returns_payer() {
let payer = Keypair::new();
let pay_to = Keypair::new().pubkey().to_string();
let r = reqs(USDC, &pay_to, 1_000);
let payload = build_payment_payload(&payer, &r, DECIMALS).unwrap();
let resp = verify(&payload.payload, &r);
assert!(resp.is_valid, "reason: {:?}", resp.invalid_reason);
assert_eq!(
resp.payer.as_deref(),
Some(payer.pubkey().to_string().as_str())
);
}
#[test]
fn tampered_amount_is_rejected() {
let payer = Keypair::new();
let pay_to = Keypair::new().pubkey().to_string();
let payload = build_payment_payload(&payer, &reqs(USDC, &pay_to, 1_000), DECIMALS).unwrap();
let resp = verify(&payload.payload, &reqs(USDC, &pay_to, 2_000));
assert!(!resp.is_valid);
assert!(resp.invalid_reason.unwrap().contains("amount"));
}
#[test]
fn wrong_destination_is_rejected() {
let payer = Keypair::new();
let real_dest = Keypair::new().pubkey().to_string();
let attacker_dest = Keypair::new().pubkey().to_string();
let payload =
build_payment_payload(&payer, &reqs(USDC, &real_dest, 1_000), DECIMALS).unwrap();
let resp = verify(&payload.payload, &reqs(USDC, &attacker_dest, 1_000));
assert!(!resp.is_valid);
assert!(resp.invalid_reason.unwrap().contains("destination"));
}
#[test]
fn wrong_asset_mint_is_rejected() {
let payer = Keypair::new();
let pay_to = Keypair::new().pubkey().to_string();
let other_mint = Keypair::new().pubkey().to_string();
let payload =
build_payment_payload(&payer, &reqs(&other_mint, &pay_to, 1_000), DECIMALS).unwrap();
let resp = verify(&payload.payload, &reqs(USDC, &pay_to, 1_000));
assert!(!resp.is_valid);
let reason = resp.invalid_reason.unwrap();
assert!(reason.contains("mint") || reason.contains("destination"));
}
#[test]
fn unsigned_transaction_is_rejected() {
let payer = Keypair::new();
let pay_to = Pubkey::new_unique();
let asset = Pubkey::from_str(USDC).unwrap();
let source = get_associated_token_address(&payer.pubkey(), &asset);
let dest = get_associated_token_address(&pay_to, &asset);
let ix = transfer_checked(
&spl_token::id(),
&source,
&asset,
&dest,
&payer.pubkey(),
&[],
1_000,
DECIMALS,
)
.unwrap();
let msg = Message::new(
&[
ComputeBudgetInstruction::set_compute_unit_limit(50_000),
ComputeBudgetInstruction::set_compute_unit_price(1),
ix,
],
None,
);
let tx = Transaction::new_unsigned(msg);
let b64 =
base64::engine::general_purpose::STANDARD.encode(bincode::serialize(&tx).unwrap());
let resp = verify(
&SvmExactPayload { transaction: b64 },
&reqs(USDC, &pay_to.to_string(), 1_000),
);
assert!(!resp.is_valid);
assert!(resp.invalid_reason.unwrap().contains("not signed"));
}
#[test]
fn too_few_instructions_is_rejected() {
let payer = Keypair::new();
let pay_to = Pubkey::new_unique();
let asset = Pubkey::from_str(USDC).unwrap();
let source = get_associated_token_address(&payer.pubkey(), &asset);
let dest = get_associated_token_address(&pay_to, &asset);
let ix = transfer_checked(
&spl_token::id(),
&source,
&asset,
&dest,
&payer.pubkey(),
&[],
1_000,
DECIMALS,
)
.unwrap();
let msg = Message::new(&[ix], Some(&payer.pubkey()));
let mut tx = Transaction::new_unsigned(msg);
tx.partial_sign(&[&payer], solana_sdk::hash::Hash::default());
let b64 =
base64::engine::general_purpose::STANDARD.encode(bincode::serialize(&tx).unwrap());
let resp = verify(
&SvmExactPayload { transaction: b64 },
&reqs(USDC, &pay_to.to_string(), 1_000),
);
assert!(!resp.is_valid);
assert!(resp.invalid_reason.unwrap().contains("instruction"));
}
#[test]
fn malformed_transaction_is_rejected_not_panicked() {
let resp = verify(
&SvmExactPayload {
transaction: "not-base64-!!!".into(),
},
&reqs(USDC, &Pubkey::new_unique().to_string(), 1_000),
);
assert!(!resp.is_valid);
}
}