Skip to main content

chio_settle/
solana.rs

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}