1use bs58::decode as bs58_decode;
2use chio_core::canonical::canonical_json_bytes;
3use chio_core::capability::scope::MonetaryAmount;
4use chio_core::receipt::body::ChioReceipt;
5use chio_core::web3::identity::{
6 verify_web3_identity_binding, SignedWeb3IdentityBinding, Web3KeyBindingPurpose,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::config::SettlementEvidenceConfig;
11use crate::{SettlementCommitment, SettlementError};
12
13pub const SOLANA_ED25519_PROGRAM_ID: &str = "Ed25519SigVerify111111111111111111111111111";
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(deny_unknown_fields)]
17pub struct SolanaSettlementConfig {
18 pub chain_id: String,
19 pub cluster: String,
20 pub program_id: String,
21 pub settlement_mint: String,
22 pub chio_minor_unit_decimals: u8,
23 pub token_minor_unit_decimals: u8,
24 #[serde(default)]
25 pub evidence_substrate: SettlementEvidenceConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct SolanaSettlementRequest {
31 pub dispatch_id: String,
32 pub capability_id: String,
33 pub payer_address: String,
34 pub beneficiary_address: String,
35 pub settlement_amount: MonetaryAmount,
36 pub recent_blockhash: String,
37 pub receipt: ChioReceipt,
38 pub binding: SignedWeb3IdentityBinding,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub note: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(deny_unknown_fields)]
45pub struct PreparedSolanaSettlement {
46 pub dispatch_id: String,
47 pub chain_id: String,
48 pub cluster: String,
49 pub program_id: String,
50 pub payer_address: String,
51 pub beneficiary_address: String,
52 pub settlement_mint: String,
53 pub capability_commitment: String,
54 pub receipt_hash: String,
55 pub settlement_amount_minor_units: u64,
56 pub recent_blockhash: String,
57 pub ed25519_program_id: String,
58 pub ed25519_signature: String,
59 pub chio_public_key: String,
60 pub instruction_data_hex: String,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub note: Option<String>,
63}
64
65impl PreparedSolanaSettlement {
66 #[must_use]
67 pub fn commitment(&self, settlement_amount: MonetaryAmount) -> SettlementCommitment {
68 SettlementCommitment {
69 chain_id: self.chain_id.clone(),
70 lane_kind: "solana_native_ed25519".to_string(),
71 capability_commitment: self.capability_commitment.clone(),
72 receipt_reference: self.receipt_hash.clone(),
73 operator_identity: self.beneficiary_address.clone(),
74 settlement_amount,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(deny_unknown_fields)]
81pub struct CommitmentConsistencyReport {
82 pub left_chain_id: String,
83 pub right_chain_id: String,
84 pub same_capability_commitment: bool,
85 pub same_receipt_reference: bool,
86 pub same_operator_identity: bool,
87 pub same_amount: bool,
88 pub overall_consistent: bool,
89}
90
91pub fn verify_solana_binding_and_receipt(
92 chain_id: &str,
93 receipt: &ChioReceipt,
94 binding: &SignedWeb3IdentityBinding,
95) -> Result<(), SettlementError> {
96 verify_web3_identity_binding(binding)
97 .map_err(|error| SettlementError::InvalidBinding(error.to_string()))?;
98 if !binding
99 .certificate
100 .purpose
101 .contains(&Web3KeyBindingPurpose::Settle)
102 {
103 return Err(SettlementError::InvalidBinding(
104 "binding does not include settle purpose".to_string(),
105 ));
106 }
107 if !binding
108 .certificate
109 .chain_scope
110 .iter()
111 .any(|scope| scope == chain_id)
112 {
113 return Err(SettlementError::InvalidBinding(format!(
114 "binding does not cover {chain_id}"
115 )));
116 }
117 bs58_decode(&binding.certificate.settlement_address)
118 .into_vec()
119 .map_err(|error| SettlementError::InvalidBinding(error.to_string()))?;
120 let verified = receipt
121 .verify_signature()
122 .map_err(|error| SettlementError::Verification(error.to_string()))?;
123 if !verified {
124 return Err(SettlementError::Verification(
125 "receipt Ed25519 signature verification failed".to_string(),
126 ));
127 }
128 if receipt.kernel_key != binding.certificate.chio_public_key {
129 return Err(SettlementError::InvalidBinding(
130 "receipt kernel key must match the Solana settlement binding public key".to_string(),
131 ));
132 }
133 Ok(())
134}
135
136pub fn prepare_solana_settlement(
137 config: &SolanaSettlementConfig,
138 request: &SolanaSettlementRequest,
139) -> Result<PreparedSolanaSettlement, SettlementError> {
140 config.validate()?;
141 if config.chain_id.trim().is_empty()
142 || config.cluster.trim().is_empty()
143 || config.program_id.trim().is_empty()
144 || config.settlement_mint.trim().is_empty()
145 {
146 return Err(SettlementError::InvalidInput(
147 "solana settlement config is incomplete".to_string(),
148 ));
149 }
150 verify_solana_binding_and_receipt(&config.chain_id, &request.receipt, &request.binding)?;
151 for (value, field) in [
152 (request.payer_address.as_str(), "payer_address"),
153 (request.beneficiary_address.as_str(), "beneficiary_address"),
154 (request.recent_blockhash.as_str(), "recent_blockhash"),
155 ] {
156 bs58_decode(value)
157 .into_vec()
158 .map_err(|error| SettlementError::InvalidInput(format!("{field}: {error}")))?;
159 }
160 let scale = 10_u64
161 .checked_pow(u32::from(
162 config
163 .token_minor_unit_decimals
164 .checked_sub(config.chio_minor_unit_decimals)
165 .ok_or_else(|| {
166 SettlementError::InvalidInput(
167 "token decimals must be >= Chio decimals for Solana settlement".to_string(),
168 )
169 })?,
170 ))
171 .ok_or_else(|| SettlementError::InvalidInput("amount scaling overflowed".to_string()))?;
172 let settlement_amount_minor_units = request
173 .settlement_amount
174 .units
175 .checked_mul(scale)
176 .ok_or_else(|| SettlementError::InvalidInput("scaled amount overflowed".to_string()))?;
177 let receipt_hash = chio_core::hashing::sha256(
178 &canonical_json_bytes(&request.receipt.body())
179 .map_err(|error| SettlementError::Serialization(error.to_string()))?,
180 );
181 let instruction_payload = serde_json::json!({
182 "schema": "chio.settle.solana-release.v1",
183 "dispatchId": request.dispatch_id,
184 "capabilityCommitment": chio_core::hashing::sha256(request.capability_id.as_bytes()).to_hex_prefixed(),
185 "receiptHash": receipt_hash.to_hex_prefixed(),
186 "payer": request.payer_address,
187 "beneficiary": request.beneficiary_address,
188 "mint": config.settlement_mint,
189 "amountMinorUnits": settlement_amount_minor_units,
190 "recentBlockhash": request.recent_blockhash,
191 });
192 let instruction_data_hex = hex::encode(
193 canonical_json_bytes(&instruction_payload)
194 .map_err(|error| SettlementError::Serialization(error.to_string()))?,
195 );
196 Ok(PreparedSolanaSettlement {
197 dispatch_id: request.dispatch_id.clone(),
198 chain_id: config.chain_id.clone(),
199 cluster: config.cluster.clone(),
200 program_id: config.program_id.clone(),
201 payer_address: request.payer_address.clone(),
202 beneficiary_address: request.beneficiary_address.clone(),
203 settlement_mint: config.settlement_mint.clone(),
204 capability_commitment: chio_core::hashing::sha256(request.capability_id.as_bytes())
205 .to_hex_prefixed(),
206 receipt_hash: receipt_hash.to_hex_prefixed(),
207 settlement_amount_minor_units,
208 recent_blockhash: request.recent_blockhash.clone(),
209 ed25519_program_id: SOLANA_ED25519_PROGRAM_ID.to_string(),
210 ed25519_signature: request.receipt.signature.to_hex(),
211 chio_public_key: request.receipt.kernel_key.to_hex(),
212 instruction_data_hex,
213 note: request.note.clone(),
214 })
215}
216
217impl SolanaSettlementConfig {
218 pub fn validate(&self) -> Result<(), SettlementError> {
219 self.evidence_substrate.validate()?;
220 Ok(())
221 }
222}
223
224pub fn compare_commitments(
225 left: &SettlementCommitment,
226 right: &SettlementCommitment,
227) -> CommitmentConsistencyReport {
228 let same_capability_commitment = left.capability_commitment == right.capability_commitment;
229 let same_receipt_reference = left.receipt_reference == right.receipt_reference;
230 let same_operator_identity = left.operator_identity == right.operator_identity;
231 let same_amount = left.settlement_amount == right.settlement_amount;
232 CommitmentConsistencyReport {
233 left_chain_id: left.chain_id.clone(),
234 right_chain_id: right.chain_id.clone(),
235 same_capability_commitment,
236 same_receipt_reference,
237 same_operator_identity,
238 same_amount,
239 overall_consistent: same_capability_commitment
240 && same_receipt_reference
241 && same_operator_identity
242 && same_amount,
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use chio_core::crypto::Keypair;
250 use chio_core::receipt::{
251 body::ChioReceipt, body::ChioReceiptBody, decision::Decision, decision::ToolCallAction,
252 };
253 use chio_core::web3::identity::{SignedWeb3IdentityBinding, Web3IdentityBindingCertificate};
254 use serde_json::json;
255
256 use chio_test_support::prelude::*;
257
258 fn sample_request() -> (SolanaSettlementConfig, SolanaSettlementRequest) {
259 let keypair = Keypair::from_seed_hex(
260 "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
261 )
262 .test_unwrap();
263 let receipt = ChioReceipt::sign(
264 ChioReceiptBody {
265 id: "rcpt-sol-1".to_string(),
266 timestamp: 1_743_292_800,
267 capability_id: "cap-sol-1".to_string(),
268 tool_server: "chio-settle".to_string(),
269 tool_name: "release_escrow".to_string(),
270 action: ToolCallAction::from_parameters(json!({"amount": 125})).test_unwrap(),
271 decision: Some(Decision::Allow),
272 receipt_kind: Default::default(),
273 boundary_class: Default::default(),
274 observation_outcome: None,
275 tool_origin: Default::default(),
276 redaction_mode: Default::default(),
277 actor_chain: Vec::new(),
278 content_hash: "content".to_string(),
279 policy_hash: "policy".to_string(),
280 evidence: Vec::new(),
281 metadata: None,
282 trust_level: chio_core::receipt::kinds::TrustLevel::default(),
283 tenant_id: None,
284 kernel_key: keypair.public_key(),
285 bbs_projection_version: None,
286 },
287 &keypair,
288 )
289 .test_unwrap();
290 let binding = SignedWeb3IdentityBinding {
291 certificate: Web3IdentityBindingCertificate {
292 schema: chio_core::web3::identity::CHIO_KEY_BINDING_CERTIFICATE_SCHEMA.to_string(),
293 chio_identity: format!("did:chio:{}", keypair.public_key().to_hex()),
294 chio_public_key: keypair.public_key(),
295 chain_scope: vec!["solana:mainnet".to_string()],
296 purpose: vec![Web3KeyBindingPurpose::Settle],
297 settlement_address: "9xQeWvG816bUx9EPjHmaT23yvVMm82oVxDgRmi1hM2Xy".to_string(),
298 issued_at: 1_743_292_800,
299 expires_at: 1_774_828_800,
300 nonce: "nonce".to_string(),
301 },
302 signature: {
303 let certificate = Web3IdentityBindingCertificate {
304 schema: chio_core::web3::identity::CHIO_KEY_BINDING_CERTIFICATE_SCHEMA
305 .to_string(),
306 chio_identity: format!("did:chio:{}", keypair.public_key().to_hex()),
307 chio_public_key: keypair.public_key(),
308 chain_scope: vec!["solana:mainnet".to_string()],
309 purpose: vec![Web3KeyBindingPurpose::Settle],
310 settlement_address: "9xQeWvG816bUx9EPjHmaT23yvVMm82oVxDgRmi1hM2Xy".to_string(),
311 issued_at: 1_743_292_800,
312 expires_at: 1_774_828_800,
313 nonce: "nonce".to_string(),
314 };
315 keypair.sign_canonical(&certificate).test_unwrap().0
316 },
317 };
318 (
319 SolanaSettlementConfig {
320 chain_id: "solana:mainnet".to_string(),
321 cluster: "mainnet-beta".to_string(),
322 program_id: "11111111111111111111111111111111".to_string(),
323 settlement_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
324 chio_minor_unit_decimals: 2,
325 token_minor_unit_decimals: 6,
326 evidence_substrate: SettlementEvidenceConfig::default(),
327 },
328 SolanaSettlementRequest {
329 dispatch_id: "dispatch-sol-1".to_string(),
330 capability_id: "cap-sol-1".to_string(),
331 payer_address: "9xQeWvG816bUx9EPjHmaT23yvVMm82oVxDgRmi1hM2Xy".to_string(),
332 beneficiary_address: "7Yzjsq4LwTnQL4Z1cdq9VHtV6nRvWmvMRdqsiE9zraFM".to_string(),
333 settlement_amount: MonetaryAmount {
334 units: 125,
335 currency: "USD".to_string(),
336 },
337 recent_blockhash: "8f1dWnLxkn6Ne1ASt1MSQd6hw2yffA9H9n1tFoZT3zh7".to_string(),
338 receipt,
339 binding,
340 note: None,
341 },
342 )
343 }
344
345 #[test]
346 fn solana_path_verifies_binding_and_receipt() {
347 let (config, request) = sample_request();
348 let prepared = prepare_solana_settlement(&config, &request).test_unwrap();
349 assert_eq!(prepared.settlement_amount_minor_units, 1_250_000);
350 assert_eq!(prepared.ed25519_program_id, SOLANA_ED25519_PROGRAM_ID);
351 }
352
353 #[test]
354 fn solana_path_rejects_invalid_evidence_substrate() {
355 let (mut config, request) = sample_request();
356 config.evidence_substrate.checkpoint_statements = false;
357
358 let error = prepare_solana_settlement(&config, &request).test_unwrap_err();
359 assert!(error
360 .to_string()
361 .contains("kernel-signed checkpoint statements"));
362 }
363
364 #[test]
365 fn commitment_comparison_detects_parity() {
366 let left = SettlementCommitment {
367 chain_id: "eip155:8453".to_string(),
368 lane_kind: "evm_dual_signature".to_string(),
369 capability_commitment: "0x01".to_string(),
370 receipt_reference: "0x02".to_string(),
371 operator_identity: "operator".to_string(),
372 settlement_amount: MonetaryAmount {
373 units: 10,
374 currency: "USD".to_string(),
375 },
376 };
377 let right = SettlementCommitment {
378 chain_id: "solana:mainnet".to_string(),
379 lane_kind: "solana_native_ed25519".to_string(),
380 capability_commitment: "0x01".to_string(),
381 receipt_reference: "0x02".to_string(),
382 operator_identity: "operator".to_string(),
383 settlement_amount: MonetaryAmount {
384 units: 10,
385 currency: "USD".to_string(),
386 },
387 };
388 let report = compare_commitments(&left, &right);
389 assert!(report.overall_consistent);
390 }
391}