use std::path::Path;
use mpp::client::PaymentProvider;
use mpp::error::MppError;
use mpp::protocol::core::{PaymentChallenge, PaymentCredential, PaymentPayload};
use mpp::protocol::intents::ChargeRequest;
use bitrouter_config::config::WalletConfig;
use self::tx::{Pubkey, SolanaTransaction};
mod tx;
#[derive(Clone)]
pub struct SolanaChargeProvider {
wallet_name: String,
credential: String,
vault_path: Option<String>,
payer: Pubkey,
payer_b58: String,
rpc_url: String,
http_client: reqwest::Client,
}
impl SolanaChargeProvider {
pub fn new(wallet: &WalletConfig, credential: &str, rpc_url: &str) -> Result<Self, String> {
let vault = wallet.vault_path.as_deref().map(Path::new);
let info = ows_lib::get_wallet(&wallet.name, vault)
.map_err(|e| format!("failed to load wallet '{}': {e}", wallet.name))?;
let sol_account = info
.accounts
.iter()
.find(|a| a.chain_id.starts_with("solana:"))
.ok_or_else(|| format!("wallet '{}' has no Solana account", wallet.name))?;
let payer_b58 = sol_account.address.clone();
let payer_bytes = bs58::decode(&payer_b58)
.into_vec()
.map_err(|e| format!("invalid Solana address: {e}"))?;
if payer_bytes.len() != 32 {
return Err(format!(
"Solana address must be 32 bytes, got {}",
payer_bytes.len()
));
}
let mut payer = Pubkey([0u8; 32]);
payer.0.copy_from_slice(&payer_bytes);
Ok(Self {
wallet_name: wallet.name.clone(),
credential: credential.to_string(),
vault_path: wallet.vault_path.clone(),
payer,
payer_b58,
rpc_url: rpc_url.to_string(),
http_client: reqwest::Client::new(),
})
}
async fn sign_message(&self, message: &[u8]) -> Result<[u8; 64], MppError> {
let wallet_name = self.wallet_name.clone();
let credential = self.credential.clone();
let vault_path = self.vault_path.clone();
let msg = message.to_vec();
tokio::task::spawn_blocking(move || {
let vault = vault_path.as_deref().map(std::path::Path::new);
let key = ows_lib::decrypt_signing_key(
&wallet_name,
ows_core::ChainType::Solana,
&credential,
None,
vault,
)
.map_err(|e| MppError::InvalidConfig(format!("decrypt Solana key: {e}")))?;
let signer = ows_signer::signer_for_chain(ows_core::ChainType::Solana);
let output = signer
.sign(key.expose(), &msg)
.map_err(|e| MppError::InvalidSignature(Some(format!("Ed25519 sign: {e}"))))?;
if output.signature.len() != 64 {
return Err(MppError::InvalidSignature(Some(format!(
"expected 64-byte signature, got {}",
output.signature.len()
))));
}
let mut sig = [0u8; 64];
sig.copy_from_slice(&output.signature);
Ok(sig)
})
.await
.map_err(|e| MppError::Http(format!("blocking task: {e}")))?
}
async fn get_latest_blockhash(&self) -> Result<Pubkey, MppError> {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getLatestBlockhash",
"params": [{ "commitment": "confirmed" }]
});
let resp: serde_json::Value = self
.http_client
.post(&self.rpc_url)
.json(&body)
.send()
.await
.map_err(|e| MppError::Http(format!("RPC getLatestBlockhash: {e}")))?
.json()
.await
.map_err(|e| MppError::Http(format!("RPC parse: {e}")))?;
if let Some(err) = resp.get("error") {
return Err(MppError::Http(format!(
"RPC getLatestBlockhash error: {err}"
)));
}
let hash_str = resp
.pointer("/result/value/blockhash")
.and_then(|v| v.as_str())
.ok_or_else(|| MppError::Http("missing blockhash in RPC response".into()))?;
let hash_bytes = bs58::decode(hash_str)
.into_vec()
.map_err(|e| MppError::Http(format!("invalid blockhash: {e}")))?;
if hash_bytes.len() != 32 {
return Err(MppError::Http(format!(
"blockhash must be 32 bytes, got {}",
hash_bytes.len()
)));
}
let mut blockhash = Pubkey([0u8; 32]);
blockhash.0.copy_from_slice(&hash_bytes);
Ok(blockhash)
}
async fn send_transaction(&self, signed_tx: &[u8]) -> Result<String, MppError> {
let tx_b58 = bs58::encode(signed_tx).into_string();
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "sendTransaction",
"params": [
tx_b58,
{
"encoding": "base58",
"skipPreflight": false,
"preflightCommitment": "confirmed"
}
]
});
let resp: serde_json::Value = self
.http_client
.post(&self.rpc_url)
.json(&body)
.send()
.await
.map_err(|e| MppError::Http(format!("RPC sendTransaction: {e}")))?
.json()
.await
.map_err(|e| MppError::Http(format!("RPC parse: {e}")))?;
if let Some(err) = resp.get("error") {
return Err(MppError::Http(format!("RPC sendTransaction error: {err}")));
}
let tx_sig = resp
.get("result")
.and_then(|v| v.as_str())
.ok_or_else(|| MppError::Http("missing result in sendTransaction".into()))?;
Ok(tx_sig.to_string())
}
}
impl PaymentProvider for SolanaChargeProvider {
fn supports(&self, method: &str, intent: &str) -> bool {
method == "solana" && intent == "charge"
}
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
let charge_req: ChargeRequest = challenge
.request
.decode()
.map_err(|e| MppError::InvalidConfig(format!("decode charge request: {e}")))?;
let recipient_b58 = charge_req
.recipient
.as_deref()
.ok_or_else(|| MppError::InvalidConfig("missing recipient".into()))?;
let recipient = tx::pubkey_from_b58(recipient_b58)
.map_err(|e| MppError::InvalidConfig(format!("invalid recipient: {e}")))?;
let amount: u64 = charge_req
.amount
.parse()
.map_err(|_| MppError::InvalidAmount(charge_req.amount.clone()))?;
let mint_str = charge_req
.method_details
.as_ref()
.and_then(|d| d.get("mint"))
.and_then(|v| v.as_str());
let is_spl = mint_str.is_some()
|| (charge_req.currency != "SOL" && tx::pubkey_from_b58(&charge_req.currency).is_ok());
let blockhash = self.get_latest_blockhash().await?;
let (message_bytes, tx_bytes) = if is_spl {
let mint_addr = mint_str.unwrap_or(&charge_req.currency);
let mint = tx::pubkey_from_b58(mint_addr)
.map_err(|e| MppError::InvalidConfig(format!("invalid mint: {e}")))?;
let decimals: u8 = charge_req
.method_details
.as_ref()
.and_then(|d| d.get("decimals"))
.and_then(|v| v.as_u64())
.unwrap_or(6) as u8;
SolanaTransaction::spl_transfer_checked(
&self.payer,
&recipient,
&mint,
amount,
decimals,
&blockhash,
)?
} else {
SolanaTransaction::sol_transfer(&self.payer, &recipient, amount, &blockhash)?
};
let signature = self.sign_message(&message_bytes).await?;
let signed_tx = SolanaTransaction::attach_signature(&tx_bytes, &signature);
let tx_sig = self.send_transaction(&signed_tx).await?;
let echo = challenge.to_echo();
let payload = PaymentPayload::hash(&tx_sig);
let source = format!(
"did:pkh:solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:{}",
self.payer_b58
);
Ok(PaymentCredential::with_source(echo, source, payload))
}
}