diff --git a/common/src/account_transfers.rs b/common/src/account_transfers.rs index 306e64e..4e04455 100644 --- a/common/src/account_transfers.rs +++ b/common/src/account_transfers.rs @@ -64,18 +64,20 @@ pub fn ledger_funds_transfer( pub fn charge_fees_to_account_and_bump_reputation( ledger: &mut LedgerMap, + dcc_id_charge: &DccIdentity, + dcc_id_bump_reputation: &DccIdentity, amount_e9s: TokenAmount, ) -> Result<(), String> { if amount_e9s == 0 { return Ok(()); } let balance_from_after = - account_balance_get(&dcc_identity.as_icrc_compatible_account()) - amount_e9s; + account_balance_get(&dcc_id_charge.as_icrc_compatible_account()) - amount_e9s; match ledger_funds_transfer( ledger, // Burn 0 tokens, and transfer the entire amount_e9s to the fee accounts FundsTransfer::new( - dcc_identity.as_icrc_compatible_account(), + dcc_id_charge.as_icrc_compatible_account(), MINTING_ACCOUNT, amount_e9s.into(), Some(fees_sink_accounts()), @@ -88,7 +90,7 @@ pub fn charge_fees_to_account_and_bump_reputation( ) { Ok(_) => Ok(ledger_add_reputation_change( ledger, - dcc_identity, + dcc_id_bump_reputation, amount_e9s.min(i64::MAX as TokenAmount) as i64, )?), Err(e) => { diff --git a/common/src/contract_refund_request.rs b/common/src/contract_refund_request.rs new file mode 100644 index 0000000..ade6ed8 --- /dev/null +++ b/common/src/contract_refund_request.rs @@ -0,0 +1,40 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use function_name::named; +use ledger_map::LedgerMap; +use serde::{Deserialize, Serialize}; + +use crate::{fn_info, DccIdentity}; + +// TODO + +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub enum ContractRefundRequest { + V1(ContractRefundRequestV1), +} + +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct ContractRefundRequestV1 { + /// The bytes of the public key of the requester, as a vec of u8. + /// This is used to identify the requester and to verify the signature. + pub requester_pubkey_bytes: Vec, + /// The instance id for which the refund is requested. + pub instance_id: String, + /// The signature of the whole refund request. + /// This is used to verify that the refund request was made by the requester. + pub crypto_sig: Vec, +} + +#[named] +pub fn do_contract_refund_request( + _ledger_map: &LedgerMap, + pubkey_bytes: Vec, + payload_serialized: Vec, + crypto_signature: Vec, +) -> Result { + let dcc_id = DccIdentity::new_verifying_from_bytes(&pubkey_bytes).unwrap(); + dcc_id.verify_bytes(&payload_serialized, &crypto_signature)?; + + fn_info!("{}", dcc_id); + + todo!() +} diff --git a/common/src/contract_sign_reply.rs b/common/src/contract_sign_reply.rs new file mode 100644 index 0000000..ee8af2d --- /dev/null +++ b/common/src/contract_sign_reply.rs @@ -0,0 +1,125 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use function_name::named; +use ledger_map::LedgerMap; +use serde::{Deserialize, Serialize}; + +use crate::{ + amount_as_string, charge_fees_to_account_and_bump_reputation, contract_sign_fee_e9s, fn_info, + ContractSignRequestPayload, DccIdentity, LABEL_CONTRACT_SIGN_REPLY, + LABEL_CONTRACT_SIGN_REQUEST, +}; + +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct ContractSignReplyV1 { + requester_pubkey_bytes: Vec, // Public key of the original requester + request_memo: String, // Memo field of the original request + contract_id: Vec, // Contract ID of the request that we are replying to + sign_accepted: bool, // True/False to mark whether the signing was accepted or rejected by the provider + response_text: String, // Thank you note, or similar on success. Reason the request failed on failure. + response_details: String, // Instructions or a link to the detailed instructions: describing next steps, further information, etc. +} + +// Main struct for Offering Request +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub enum ContractSignReply { + V1(ContractSignReplyV1), +} + +impl ContractSignReply { + pub fn contract_id(&self) -> &[u8] { + match self { + ContractSignReply::V1(payload) => payload.contract_id.as_slice(), + } + } + pub fn requester_pubkey_bytes(&self) -> &[u8] { + match self { + ContractSignReply::V1(payload) => payload.requester_pubkey_bytes.as_slice(), + } + } + pub fn request_memo(&self) -> &String { + match self { + ContractSignReply::V1(payload) => &payload.request_memo, + } + } + pub fn sign_accepted(&self) -> bool { + match self { + ContractSignReply::V1(payload) => payload.sign_accepted, + } + } + pub fn response_text(&self) -> &String { + match self { + ContractSignReply::V1(payload) => &payload.response_text, + } + } + pub fn response_details(&self) -> &String { + match self { + ContractSignReply::V1(payload) => &payload.response_details, + } + } +} + +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct ContractSignReplyPayloadV1 { + payload_bytes: Vec, + crypto_signature: Vec, +} + +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub enum ContractSignReplyPayload { + V1(ContractSignReplyPayloadV1), +} + +impl ContractSignReplyPayload { + pub fn new(payload_bytes: Vec, crypto_signature: Vec) -> ContractSignReplyPayload { + ContractSignReplyPayload::V1(ContractSignReplyPayloadV1 { + payload_bytes, + crypto_signature, + }) + } +} + +#[named] +pub fn do_contract_sign_reply( + ledger: &mut LedgerMap, + pubkey_bytes: Vec, + reply_serialized: Vec, + crypto_signature: Vec, +) -> Result { + let dcc_id = DccIdentity::new_verifying_from_bytes(&pubkey_bytes).unwrap(); + dcc_id.verify_bytes(&reply_serialized, &crypto_signature)?; + + fn_info!("{}", dcc_id); + + let cs_reply = ContractSignReply::try_from_slice(&reply_serialized).unwrap(); + let cs_req = ledger + .get(LABEL_CONTRACT_SIGN_REQUEST, cs_reply.contract_id()) + .unwrap(); + let cs_req = ContractSignRequestPayload::try_from_slice(&cs_req).unwrap(); + let cs_req = cs_req + .deserialize_contract_sign_request() + .expect("Error deserializing original contract sign request"); + if pubkey_bytes != cs_req.provider_pubkey_bytes() { + return Err(format!( + "Contract signing reply signed and submitted by {} does not match the provider public key {} from contract req 0x{}", + dcc_id, DccIdentity::new_verifying_from_bytes(cs_req.provider_pubkey_bytes()).unwrap(), hex::encode(cs_reply.contract_id()) + )); + } + let payload = ContractSignReplyPayload::new(reply_serialized, crypto_signature); + let payload_bytes = borsh::to_vec(&payload).unwrap(); + + let fees = contract_sign_fee_e9s(cs_req.payment_amount()); + charge_fees_to_account_and_bump_reputation(ledger, &dcc_id, &dcc_id, fees)?; + + ledger.upsert( + LABEL_CONTRACT_SIGN_REPLY, + &pubkey_bytes, + payload_bytes, + ) + .map(|_| { + format!( + "Contract signing reply submitted! Thank you. You have been charged {} tokens as a fee, and your reputation has been bumped accordingly", + amount_as_string(fees) + ) + }) + .map_err(|e| e.to_string()) +} diff --git a/common/src/contract_sign_request.rs b/common/src/contract_sign_request.rs new file mode 100644 index 0000000..8a5ba46 --- /dev/null +++ b/common/src/contract_sign_request.rs @@ -0,0 +1,211 @@ +use std::io::Error; + +use borsh::{BorshDeserialize, BorshSerialize}; +use function_name::named; +use ledger_map::LedgerMap; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{ + amount_as_string, charge_fees_to_account_and_bump_reputation, fn_info, DccIdentity, + TokenAmount, LABEL_CONTRACT_SIGN_REQUEST, +}; + +pub fn contract_sign_fee_e9s(contract_value: TokenAmount) -> TokenAmount { + contract_value / 100 +} + +// Main struct for Offering Request +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub enum ContractSignRequest { + V1(ContractSignRequestV1), +} + +// Struct for requesting a contract signature, version 1. Future versions can be added below +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct ContractSignRequestV1 { + requester_pubkey_bytes: Vec, // Who is making this request? + requester_ssh_pubkey: String, // The ssh key that will be given access to the instance, preferably in ed25519 key format https://en.wikipedia.org/wiki/Ssh-keygen + requester_contact: String, // Where can the requester be contacted by the provider, if needed + provider_pubkey_bytes: Vec, // To which provider is this targeted? + offering_id: String, // Requester would like to contract this particular offering id + region_name: Option, // Optional region name + instance_id: Option, // Optional instance id that can be provided to alter the particular instance that requester already controls + instance_config: Option, // Optional configuration for the instance deployment, e.g. cloud-init + payment_amount: u64, // How much is the requester offering to pay for the contract + duration_seconds: u64, // For how many SECONDS would the requester like to sign the contract; 1 hour = 3600 seconds, 1 day = 86400 seconds + start_timestamp: Option, // Optionally, only start contract at this unix time (in seconds) UTC. This can be in the past or in the future. Default is now. + request_memo: String, // Reference to this particular request; arbitrary text. Can be used e.g. for administrative purposes +} + +impl ContractSignRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + requester_pubkey_bytes: Vec, + requester_ssh_pubkey: String, + requester_contact: String, + provider_pubkey_bytes: &[u8], + offering_id: String, + region_name: Option, + instance_id: Option, + instance_config: Option, + payment_amount: u64, + duration_seconds: u64, + start_timestamp: Option, + request_memo: String, + ) -> Self { + ContractSignRequest::V1(ContractSignRequestV1 { + requester_pubkey_bytes, + requester_ssh_pubkey, + requester_contact, + provider_pubkey_bytes: provider_pubkey_bytes.to_vec(), + offering_id, + region_name, + instance_id, + instance_config, + payment_amount, + duration_seconds, + start_timestamp, + request_memo, + }) + } + + pub fn payment_amount(&self) -> u64 { + match self { + ContractSignRequest::V1(v1) => v1.payment_amount, + } + } + + pub fn requester_pubkey_bytes(&self) -> &[u8] { + match self { + ContractSignRequest::V1(v1) => &v1.requester_pubkey_bytes, + } + } + + pub fn requester_ssh_pubkey(&self) -> &String { + match self { + ContractSignRequest::V1(v1) => &v1.requester_ssh_pubkey, + } + } + + pub fn requester_contact(&self) -> &String { + match self { + ContractSignRequest::V1(v1) => &v1.requester_contact, + } + } + + pub fn provider_pubkey_bytes(&self) -> &[u8] { + match self { + ContractSignRequest::V1(v1) => &v1.provider_pubkey_bytes, + } + } + + pub fn offering_id(&self) -> &String { + match self { + ContractSignRequest::V1(v1) => &v1.offering_id, + } + } + + pub fn instance_id(&self) -> Option<&String> { + match self { + ContractSignRequest::V1(v1) => v1.instance_id.as_ref(), + } + } + + pub fn instance_config(&self) -> Option<&String> { + match self { + ContractSignRequest::V1(v1) => v1.instance_config.as_ref(), + } + } + + pub fn rent_period_seconds(&self) -> u64 { + match self { + ContractSignRequest::V1(v1) => v1.duration_seconds, + } + } + + pub fn rent_start_timestamp(&self) -> Option { + match self { + ContractSignRequest::V1(v1) => v1.start_timestamp, + } + } + + pub fn request_memo(&self) -> &String { + match self { + ContractSignRequest::V1(v1) => &v1.request_memo, + } + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct ContractSignRequestPayloadV1 { + payload_serialized: Vec, + signature: Vec, +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub enum ContractSignRequestPayload { + V1(ContractSignRequestPayloadV1), +} + +impl ContractSignRequestPayload { + pub fn new(payload_serialized: &[u8], crypto_sig: &[u8]) -> Result { + Ok(ContractSignRequestPayload::V1( + ContractSignRequestPayloadV1 { + payload_serialized: payload_serialized.to_vec(), + signature: crypto_sig.to_vec(), + }, + )) + } + + pub fn payload_serialized(&self) -> &[u8] { + match self { + ContractSignRequestPayload::V1(v1) => v1.payload_serialized.as_slice(), + } + } + + pub fn deserialize_contract_sign_request(&self) -> Result { + ContractSignRequest::try_from_slice(self.payload_serialized()) + } + + pub fn calc_contract_id(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(self.payload_serialized()); + hasher.finalize().into() + } +} + +#[named] +pub fn do_contract_sign_request( + ledger: &mut LedgerMap, + pubkey_bytes: Vec, + request_serialized: Vec, + crypto_signature: Vec, +) -> Result { + let dcc_id = DccIdentity::new_verifying_from_bytes(&pubkey_bytes).unwrap(); + dcc_id.verify_bytes(&request_serialized, &crypto_signature)?; + + fn_info!("{}", dcc_id); + + let contract_req = ContractSignRequest::try_from_slice(&request_serialized).unwrap(); + + let fees = contract_sign_fee_e9s(contract_req.payment_amount()); + + let payload = ContractSignRequestPayload::new(&request_serialized, &crypto_signature).unwrap(); + let payload_bytes = borsh::to_vec(&payload).unwrap(); + + charge_fees_to_account_and_bump_reputation(ledger, &dcc_id, &dcc_id, fees)?; + let contract_id = payload.calc_contract_id(); + + ledger.upsert( + LABEL_CONTRACT_SIGN_REQUEST, + contract_id, + payload_bytes, + ).map(|_| { + format!( + "Contract signing req 0x{} submitted! Thank you. You have been charged {} tokens as a fee, and your reputation has been bumped accordingly. Please wait for a response.", + hex::encode(contract_id), + amount_as_string(fees) + ) + }).map_err(|e| e.to_string()) +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 3f60c65..8732560 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -3,13 +3,15 @@ pub mod account_transfers_errors; pub mod cache_balances; pub mod cache_reputation; pub mod cache_transactions; +pub mod contract_refund_request; +pub mod contract_sign_reply; +pub mod contract_sign_request; pub mod dcc_identity; pub mod ledger_cursor; pub mod ledger_refresh; pub mod offerings; pub mod profiles; pub mod registration; -pub mod renting; pub mod rewards; pub use account_transfers::*; @@ -18,6 +20,9 @@ pub use cache_balances::*; pub use cache_reputation::*; pub use cache_transactions::*; use candid::{Nat, Principal}; +pub use contract_refund_request::*; +pub use contract_sign_reply::*; +pub use contract_sign_request::*; pub use dcc_identity::{slice_to_32_bytes_array, slice_to_64_bytes_array}; use icrc_ledger_types::icrc1::account::Account as Icrc1Account; pub use ledger_cursor::*; @@ -26,7 +31,6 @@ use num_traits::cast::ToPrimitive; pub use offerings::*; pub use profiles::*; pub use registration::*; -pub use renting::*; pub use rewards::*; #[cfg(not(target_arch = "wasm32"))] @@ -48,7 +52,7 @@ use ledger_map::{debug, error, info, warn}; #[macro_export] macro_rules! fn_info { ($($arg:tt)*) => { - crate::info!( + $crate::info!( "[{}]: {}", function_name!(), format_args!($($arg)*) @@ -86,6 +90,8 @@ pub const LABEL_REPUTATION_AGE: &str = "RepAge"; pub const LABEL_REPUTATION_CHANGE: &str = "RepChange"; pub const LABEL_REWARD_DISTRIBUTION: &str = "RewardDistr"; pub const LABEL_USER_REGISTER: &str = "UserRegister"; +pub const LABEL_CONTRACT_SIGN_REQUEST: &str = "ContractSignReq"; +pub const LABEL_CONTRACT_SIGN_REPLY: &str = "ContractSignReply"; pub const MAX_NP_PROFILE_BYTES: usize = 4 * 1024; pub const MAX_NP_OFFERING_BYTES: usize = 32 * 1024; // Maximum response size (replicated execution) in bytes is 2 MiB diff --git a/common/src/offerings.rs b/common/src/offerings.rs index bae2e1a..a232b88 100644 --- a/common/src/offerings.rs +++ b/common/src/offerings.rs @@ -115,7 +115,7 @@ pub fn do_get_matching_offerings( }; match payload_decoded.offering() { Ok(offering) => { - if search_filter.is_empty() || offering.matches_search(search_filter) { + if search_filter.is_empty() || !offering.matches_search(search_filter).is_empty() { results.push((dcc_id, offering)); } } diff --git a/common/src/renting.rs b/common/src/renting.rs deleted file mode 100644 index b8d7368..0000000 --- a/common/src/renting.rs +++ /dev/null @@ -1,98 +0,0 @@ -use borsh::{BorshDeserialize, BorshSerialize}; -use serde::{Deserialize, Serialize}; - -use crate::{ - // amount_as_string, charge_fees_to_account_no_bump_reputation, info, reward_e9s_per_block, warn, - // Balance, DccIdentity, ED25519_SIGNATURE_LENGTH, LABEL_NP_OFFERING, MAX_NP_OFFERING_BYTES, - // MAX_PUBKEY_BYTES, - DccIdentity, -}; - -// Main struct for Offering Request -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] -pub enum OfferingRequest { - V1(OfferingRequestV1), -} - -// Struct for Offering Request version 1, other versions can be added below -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] -pub struct OfferingRequestV1 { - #[serde(skip, default)] - #[borsh(skip)] - requester_dcc_id: DccIdentity, // Who is making this rent request? - requester_ssh_pubkey: String, // The ssh key that will be given access to the instance, preferably in ed25519 key format https://en.wikipedia.org/wiki/Ssh-keygen - requester_contact: String, // Where can the requester be contacted by the provider, if needed - provider_pubkey_bytes: Vec, // To which provider is this targeted? - offering_id: String, // Requester would like to rent this particular offering id - instance_id: Option, // Optional instance id that can be provided to alter the particular instance a requester already controls - instance_config: Option, // Optional configuration for the rented instance, e.g. cloud-init - payment_amount: u64, // How much is the requester offering to pay for renting the resource - rent_period_seconds: u64, // For how many SECONDS would the requester like to rent the resource; 1 hour = 3600 seconds, 1 day = 86400 seconds - rent_start_timestamp: Option, // Optionally, only start renting at this unix time (in seconds) UTC. This can be in the future. - request_memo: String, // Reference to this particular request; arbitrary text. Can be used e.g. for administrative purposes -} - -impl OfferingRequest { - pub fn new( - requester_dcc_id: DccIdentity, - requester_ssh_pubkey: String, - requester_contact: String, - provider_pubkey_bytes: &[u8], - offering_id: String, - instance_id: Option, - instance_config: Option, - payment_amount: u64, - rent_period_seconds: u64, - rent_start_timestamp: Option, - request_memo: String, - ) -> Self { - OfferingRequest::V1(OfferingRequestV1 { - requester_dcc_id, - requester_ssh_pubkey, - requester_contact, - provider_pubkey_bytes: provider_pubkey_bytes.to_vec(), - offering_id, - instance_id, - instance_config, - payment_amount, - rent_period_seconds, - rent_start_timestamp, - request_memo, - }) - } - - pub fn to_payload_signed(&self) -> OfferingRequestPayload { - OfferingRequestPayload::new(self) - } - - pub fn requester_dcc_id(&self) -> &DccIdentity { - match self { - OfferingRequest::V1(request) => &request.requester_dcc_id, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct OfferingRequestPayloadV1 { - offering_request_bytes: Vec, - signature: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum OfferingRequestPayload { - V1(OfferingRequestPayloadV1), -} - -impl OfferingRequestPayload { - pub fn new(offering_request: &OfferingRequest) -> Self { - let offering_request_bytes = borsh::to_vec(&offering_request).unwrap(); - OfferingRequestPayload::V1(OfferingRequestPayloadV1 { - offering_request_bytes: offering_request_bytes.clone(), - signature: offering_request - .requester_dcc_id() - .sign(&offering_request_bytes) - .unwrap() - .to_vec(), - }) - } -} diff --git a/docs/whitepaper/figures/proposed_architecture.mmd b/docs/whitepaper/figures/proposed_architecture.mmd index 955eaf7..645bd3e 100644 --- a/docs/whitepaper/figures/proposed_architecture.mmd +++ b/docs/whitepaper/figures/proposed_architecture.mmd @@ -8,7 +8,7 @@ graph TB WS -- "Serves Requests" --> EU["End User"] subgraph BC["Blockchain: Reputation and Financial Transactions"] - LE["Node Renting Event"] -- "Records Node Renting" --> Ledger + LE["Contract Start Event"] -- "Records Contract Start" --> Ledger Ledger -- "Increases Developer Reputation" --> DREP["Developer Reputation"] Ledger -- "Increases Node Provider Reputation" --> NREP["Node Provider Reputation"] DREP -- "Can Spend Reputation to Reduce Node Provider Reputation" --> NREP diff --git a/docs/whitepaper/implementation.tex b/docs/whitepaper/implementation.tex index ff806be..231e47f 100644 --- a/docs/whitepaper/implementation.tex +++ b/docs/whitepaper/implementation.tex @@ -109,12 +109,12 @@ \subsection{Resource Allocation} \item Required storage (which can be unlimited) \item Required network bandwidth (which can be unlimited) \item Minimum node provider reputation - \item Renting period + \item Contract period \end{itemize} -The matching engine processes these requirements and returns a list of suitable nodes. The developer then selects one or more nodes from this list and sends a request to confirm the rental. Upon receiving payment from the developer, the matching engine provides a rental contract ID. The developer can use this contract ID to start utilizing the node. +The matching engine processes these requirements and returns a list of suitable nodes. The developer then selects one or more nodes from this list and sends a request to start the contract. Upon receiving payment from the developer, the matching engine provides a rental contract ID. The developer can use this contract ID to start utilizing the node. -If the developer wishes to extend the rental period, they can make another request at a later time. The process for extending the rental period is similar to the original rental process, with the key difference being that the developer already has access to the node. +If the developer wishes to extend the rental period, they can make another request at a later time. The process for extending the contract duration is similar to the original rental process, with the key difference being that the developer already has access to the node. \subsection{Token Economics (Tokenomics)} diff --git a/ic-canister/decent_cloud.did b/ic-canister/decent_cloud.did index a20afc6..bc7616f 100644 --- a/ic-canister/decent_cloud.did +++ b/ic-canister/decent_cloud.did @@ -354,20 +354,6 @@ type OfferingEntry = record { offering_compressed: vec nat8; }; -type OfferingRentReply = record { - requester_pubkey_bytes: vec nat8; // Public key of the original requester - request_memo: text; // Memo field of the original request - rent_success: bool; // Short boolean to mark whether the renting was accepted or rejected by the provider - response_text: text; // Thank you note, or similar on success. Reason the request failed on failure. - response_details: text; // Instructions or a link to the detailed instructions: describing next steps, further information, etc. -}; - -type RefundRequest = record { // TODO - requester_pubkey_bytes: vec nat8; // Who is making this request? - instance_id: text; // instance id for which a refund is requested - crypto_sig: vec nat8; // The whole request needs to be signed by the requester's private key -}; - service : { // Node Provider (NP) management operations // crypto_sig can be used to cryptographically verify the authenticity of the message @@ -378,8 +364,13 @@ service : { node_provider_get_profile_by_pubkey_bytes: (vec nat8) -> (opt text) query; node_provider_get_profile_by_principal: (principal) -> (opt text) query; offering_search: (search_query: text) -> (vec OfferingEntry) query; - offering_request: (offering_rent_request_payload: vec nat8, crypto_sig: vec nat8) -> (ResultString); - offering_reply: (offering_rent_reply_payload: vec nat8, crypto_sig: vec nat8) -> (ResultString); + + // Contract signing and replying + contract_sign_request: (pubkey_bytes: vec nat8, contract_info_serialized: vec nat8, crypto_sig: vec nat8) -> (ResultString); + contract_sign_reply: (pubkey_bytes: vec nat8, contract_reply_serialized: vec nat8, crypto_sig: vec nat8) -> (ResultString); + // FIXME + // contract_extend + // contract_cancel // User management operations user_register: (pubkey_bytes: vec nat8, crypto_sig: vec nat8) -> (ResultString); diff --git a/ic-canister/src/canister_backend/generic.rs b/ic-canister/src/canister_backend/generic.rs index 55ac7a1..1e65369 100644 --- a/ic-canister/src/canister_backend/generic.rs +++ b/ic-canister/src/canister_backend/generic.rs @@ -8,8 +8,8 @@ use dcc_common::{ reward_e9s_per_block_recalculate, rewards_applied_np_count, rewards_distribute, rewards_pending_e9s, set_test_config, FundsTransfer, LedgerCursor, TokenAmount, BLOCK_INTERVAL_SECS, CACHE_TXS_NUM_COMMITTED, DATA_PULL_BYTES_BEFORE_LEN, - LABEL_DC_TOKEN_TRANSFER, LABEL_NP_CHECK_IN, LABEL_NP_OFFERING, LABEL_NP_PROFILE, - LABEL_NP_REGISTER, LABEL_REWARD_DISTRIBUTION, LABEL_USER_REGISTER, + LABEL_CONTRACT_SIGN_REQUEST, LABEL_DC_TOKEN_TRANSFER, LABEL_NP_CHECK_IN, LABEL_NP_OFFERING, + LABEL_NP_PROFILE, LABEL_NP_REGISTER, LABEL_REWARD_DISTRIBUTION, LABEL_USER_REGISTER, MAX_RESPONSE_BYTES_NON_REPLICATED, }; use flate2::{write::ZlibEncoder, Compression}; @@ -32,6 +32,7 @@ thread_local! { LABEL_REWARD_DISTRIBUTION.to_string(), LABEL_NP_PROFILE.to_string(), LABEL_NP_OFFERING.to_string(), + LABEL_CONTRACT_SIGN_REQUEST.to_string(), ])).expect("Failed to create LedgerMap")); pub(crate) static AUTHORIZED_PUSHER: RefCell> = const { RefCell::new(None) }; #[cfg(target_arch = "wasm32")] @@ -266,6 +267,36 @@ pub(crate) fn _offering_search(query: String) -> Vec<(Vec, Vec)> { response } +pub(crate) fn _contract_sign_request( + pubkey_bytes: Vec, + request_serialized: Vec, + crypto_signature: Vec, +) -> Result { + LEDGER_MAP.with(|ledger| { + dcc_common::do_contract_sign_request( + &mut ledger.borrow_mut(), + pubkey_bytes, + request_serialized, + crypto_signature, + ) + }) +} + +pub(crate) fn _contract_sign_reply( + pubkey_bytes: Vec, + reply_serialized: Vec, + crypto_signature: Vec, +) -> Result { + LEDGER_MAP.with(|ledger| { + dcc_common::do_contract_sign_reply( + &mut ledger.borrow_mut(), + pubkey_bytes, + reply_serialized, + crypto_signature, + ) + }) +} + pub(crate) fn _get_identity_reputation(identity: Vec) -> u64 { reputation_get(identity) } diff --git a/ic-canister/src/canister_endpoints/generic.rs b/ic-canister/src/canister_endpoints/generic.rs index 6771820..13f6c30 100644 --- a/ic-canister/src/canister_endpoints/generic.rs +++ b/ic-canister/src/canister_endpoints/generic.rs @@ -70,6 +70,24 @@ fn offering_search(search_query: String) -> Vec<(Vec, Vec)> { _offering_search(search_query) } +#[ic_cdk::update] +fn contract_sign_request( + pubkey_bytes: Vec, + request_serialized: Vec, + crypto_signature: Vec, +) -> Result { + _contract_sign_request(pubkey_bytes, request_serialized, crypto_signature) +} + +#[ic_cdk::update] +fn contract_sign_reply( + pubkey_bytes: Vec, + reply_serialized: Vec, + crypto_signature: Vec, +) -> Result { + _contract_sign_reply(pubkey_bytes, reply_serialized, crypto_signature) +} + #[ic_cdk::query] fn node_provider_get_profile_by_pubkey_bytes(pubkey_bytes: Vec) -> Option { _node_provider_get_profile_by_pubkey_bytes(pubkey_bytes) diff --git a/ic-canister/tests/test_canister.rs b/ic-canister/tests/test_canister.rs index ed76a4f..87e6289 100644 --- a/ic-canister/tests/test_canister.rs +++ b/ic-canister/tests/test_canister.rs @@ -634,36 +634,62 @@ fn offering_search + candid::CandidType + ?Sized>( .collect() } -// fn offering_request( +fn contract_sign_request( + pic: &PocketIc, + can: Principal, + requester_dcc_id: DccIdentity, + provider_pubkey_bytes: &[u8], + offering_id: &str, +) -> Result { + let requester_pubkey_bytes = requester_dcc_id.to_bytes_verifying(); + let req = ContractSignRequest::new( + requester_pubkey_bytes.clone(), + "invalid test ssh key".to_string(), + "invalid test contact info".to_string(), + provider_pubkey_bytes, + offering_id.to_string(), + None, + None, + None, + 100, + 3600, + None, + "memo".to_string(), + ); + let payload_bytes = borsh::to_vec(&req).unwrap(); + let payload_sig_bytes = requester_dcc_id.sign(&payload_bytes).unwrap().to_bytes(); + update_check_and_decode!( + pic, + can, + requester_dcc_id.to_ic_principal(), + "contract_sign_request", + Encode!(&requester_pubkey_bytes, &payload_bytes, &payload_sig_bytes).unwrap(), + Result + ) +} + +// FIXME: add tests for contract_sign_request_list +// fn contract_sign_request_list_open( +// pic: &PocketIc, +// can: Principal, +// provider_dcc_id: &DccIdentity, +// ) -> Vec { + +// } + +// fn contract_sign_reply( // pic: &PocketIc, // can: Principal, // requester_dcc_id: DccIdentity, -// provider_pubkey_bytes: &[u8], -// offering_id: &str, +// reply: &ContractSignReply, // ) -> Result { -// let payload = OfferingRequest::new( -// requester_dcc_id, -// "fake ssh key".to_string(), -// "fake contact info".to_string(), -// provider_pubkey_bytes, -// offering_id.to_string(), -// None, -// None, -// 100, -// 3600, -// None, -// "memo".to_string(), -// ); -// let payload_bytes = payload.to_payload_signed(); +// let payload_bytes = borsh::to_vec(&reply).unwrap(); +// let payload_sig_bytes = requester_dcc_id.sign(&payload_bytes).unwrap().to_bytes(); // update_check_and_decode!( // pic, // can, // requester_dcc_id.to_ic_principal(), -// "offering_request", -// Encode!(&payload_bytes).unwrap(), -// Result -// ) -// } +// "contract_sign_reply", #[test] fn test_offerings() { @@ -715,4 +741,23 @@ fn test_offerings() { ); let search_results = offering_search(&p, c, "memory < 512MB"); assert_eq!(search_results.len(), 0); + + // Sign a contract + let offering_id = offering.matches_search("memory >= 512MB")[0].clone(); + assert_eq!(offering_id, "xxx-small"); + + let u1 = user_register(&p, c, b"u1", 2 * DC_TOKEN_DECIMALS_DIV).0; + contract_sign_request(&p, c, u1, &np1.to_bytes_verifying(), &offering_id).unwrap(); + + contract_sign_reply( + &p, + c, + b"u1", + b"np1", + b"xxx-small", + b"memo", + b"reply", + b"signature", + ); + test_ffwd_to_next_block(ts_ns, &p, c); } diff --git a/np-offering/src/lib.rs b/np-offering/src/lib.rs index 10e763e..59701fc 100644 --- a/np-offering/src/lib.rs +++ b/np-offering/src/lib.rs @@ -1,19 +1,19 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use np_json_search::value_matches; +use np_json_search::value_matches_with_parents; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use serde_yaml_ng::{self, Value as YamlValue}; use std::{collections::HashMap, fmt}; // Define the Offering enum with version-specific variants -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub enum Offering { V0_1_0(CloudProviderOfferingV0_1_0), // Future versions can be added here } // Main struct for Cloud Provider Offering version 0.1.0 -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct CloudProviderOfferingV0_1_0 { pub kind: String, pub metadata: Metadata, @@ -28,19 +28,19 @@ pub struct CloudProviderOfferingV0_1_0 { json_value: JsonValue, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Metadata { pub name: String, pub version: String, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Provider { pub name: String, pub description: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct DefaultSpec { pub compliance: Option>, pub sla: Option, @@ -53,7 +53,7 @@ pub struct DefaultSpec { pub service_integrations: Option, } -#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct SLA { pub uptime: Option, pub measurement_period: Option, @@ -62,13 +62,13 @@ pub struct SLA { pub maintenance: Option, } -#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Support { pub levels: Option>, pub response_time: Option, } -#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct ResponseTime { pub critical: Option, pub high: Option, @@ -76,25 +76,25 @@ pub struct ResponseTime { pub low: Option, } -#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Compensation { pub less_than: Option, pub more_than: Option, pub credit_percentage: Option, } -#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Maintenance { pub window: Option, pub notification_period: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct MachineSpec { pub instance_types: Vec, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct InstanceType { pub id: String, #[serde(rename = "type")] @@ -112,7 +112,7 @@ pub struct InstanceType { pub ai_spec: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct GPU { pub count: u32, #[serde(rename = "type")] @@ -120,7 +120,7 @@ pub struct GPU { pub memory: String, } -#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Storage { #[serde(rename = "type")] pub type_: String, @@ -128,19 +128,19 @@ pub struct Storage { pub iops: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct MetadataSpec { pub optimized_for: Option, pub availability: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct NetworkSpecDetails { pub bandwidth: Option, pub latency: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct AISpec { pub framework_optimizations: Option>, pub software_stack: Option, @@ -148,12 +148,12 @@ pub struct AISpec { pub distributed_training_support: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct SoftwareStack { pub preinstalled: Option>, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct NetworkSpec { pub vpc_support: Option, pub public_ip: Option, @@ -162,45 +162,45 @@ pub struct NetworkSpec { pub firewalls: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct LoadBalancers { #[serde(rename = "type")] pub types: Option>, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Firewalls { pub stateful: Option, pub stateless: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Security { pub data_encryption: Option, pub identity_and_access_management: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct DataEncryption { pub at_rest: Option, pub in_transit: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct IAM { pub multi_factor_authentication: Option, pub role_based_access_control: Option, pub single_sign_on: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Monitoring { pub enabled: Option, pub metrics: Option, pub logging: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Metrics { pub cpu_utilization: Option, pub memory_usage: Option, @@ -208,13 +208,13 @@ pub struct Metrics { pub network_traffic: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Logging { pub enabled: Option, pub log_retention: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Backup { pub enabled: Option, pub frequency: Option, @@ -222,33 +222,33 @@ pub struct Backup { pub disaster_recovery: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct DisasterRecovery { pub cross_region_replication: Option, pub failover_time: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct CostOptimization { pub spot_instances_available: Option, pub savings_plans: Option>, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct SavingsPlan { #[serde(rename = "type")] pub type_: String, pub discount: String, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct ServiceIntegrations { pub databases: Option>, pub storage_services: Option>, pub messaging_services: Option>, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Region { pub name: String, pub description: Option, @@ -258,20 +258,20 @@ pub struct Region { pub availability_zones: Option>, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct Geography { pub continent: Option, pub country: Option, pub iso_codes: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct IsoCodes { pub country_code: Option, pub region_code: Option, } -#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Debug, Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone)] pub struct AvailabilityZone { pub name: String, pub description: Option, @@ -325,7 +325,7 @@ impl Offering { } } - pub fn matches_search(&self, search_str: &str) -> bool { + pub fn matches_search(&self, search_str: &str) -> Vec { match self { Offering::V0_1_0(offering) => offering.matches_search(search_str), } @@ -344,6 +344,18 @@ impl Offering { } } + pub fn get_instance_with_id(&self, id: &str) -> Option { + match self { + Offering::V0_1_0(offering) => offering.get_instance_with_id(id), + } + } + + pub fn json_value(&self) -> &serde_json::Value { + match self { + Offering::V0_1_0(offering) => &offering.json_value, + } + } + pub fn as_json_string(&self) -> Result { match self { Offering::V0_1_0(offering) => { @@ -354,8 +366,30 @@ impl Offering { } impl CloudProviderOfferingV0_1_0 { - pub fn matches_search(&self, search_str: &str) -> bool { - value_matches(&self.json_value, search_str) + pub fn matches_search(&self, search_str: &str) -> Vec { + value_matches_with_parents(&self.json_value, "instance_types.id", search_str) + } + + pub fn get_instance_with_id(&self, id: &str) -> Option { + for region in &self.regions { + if let Some(machine_spec) = ®ion.machine_spec { + for instance_type in &machine_spec.instance_types { + if instance_type.id == id { + return Some(instance_type.clone()); + } + } + } + } + if let Some(default_spec) = &self.defaults { + if let Some(machine_spec) = &default_spec.machine_spec { + for instance_type in &machine_spec.instance_types { + if instance_type.id == id { + return Some(instance_type.clone()); + } + } + } + } + None } } @@ -445,33 +479,63 @@ regions: fn test_search_offering() { let offering = Offering::new_from_str(SAMPLE_YAML, "yaml").expect("Failed to parse YAML"); // Test matches_search with spaces before and after - assert!(offering.matches_search("name =GenericCloudService")); - assert!(offering.matches_search("name=GenericCloudService")); - assert!(offering.matches_search("name= GenericCloudService")); - - assert!(offering.matches_search("provider.name=generic cloud provider")); - assert!(offering.matches_search("name contains Cloud")); - assert!(offering.matches_search("name contains CloudService")); - assert!(offering.matches_search("name contains Service")); - assert!(offering.matches_search("name contains GenericCloudService")); - assert!(offering.matches_search("name startswith GenericCloudService")); - assert!(offering.matches_search("name endswith Service")); - - assert!(offering.matches_search("type=memory-optimized")); - assert!(offering.matches_search("type=memory-optimized and name=GenericCloudService")); - - assert!(offering.matches_search("name endswith Service")); - - assert!(offering.matches_search("regions.name=eu-central-1")); - assert!(offering.matches_search("pricing.on_demand.hour <= 0.05")); - assert!(!offering.matches_search("pricing.on_demand.hour < 0.05")); - assert!(!offering.matches_search("pricing.on_demand.hour <= 0.01")); - assert!(offering.matches_search("pricing.on_demand.hour >= 0.05")); - assert!(offering.matches_search("pricing.on_demand.hour >= 0.15")); - assert!(!offering.matches_search("pricing.on_demand.hour > 0.15")); - assert!(!offering.matches_search("pricing.on_demand.hour > 0.5")); - assert!(offering.matches_search("type=memory-optimized")); - assert!(!offering.matches_search("nonexistent=value")); + assert!(!offering + .matches_search("name =GenericCloudService") + .is_empty()); + assert!(!offering + .matches_search("name=GenericCloudService") + .is_empty()); + assert!(!offering + .matches_search("name= GenericCloudService") + .is_empty()); + + assert!(!offering + .matches_search("provider.name=generic cloud provider") + .is_empty()); + assert!(!offering.matches_search("name contains Cloud").is_empty()); + assert!(!offering + .matches_search("name contains CloudService") + .is_empty()); + assert!(!offering.matches_search("name contains Service").is_empty()); + assert!(!offering + .matches_search("name contains GenericCloudService") + .is_empty()); + assert!(!offering + .matches_search("name startswith GenericCloudService") + .is_empty()); + assert!(!offering.matches_search("name endswith Service").is_empty()); + + assert!(!offering.matches_search("type=memory-optimized").is_empty()); + assert!(!offering + .matches_search("type=memory-optimized and name=GenericCloudService") + .is_empty()); + assert!(!offering.matches_search("name endswith Service").is_empty()); + assert!(!offering + .matches_search("regions.name=eu-central-1") + .is_empty()); + assert!(!offering + .matches_search("pricing.on_demand.hour <= 0.05") + .is_empty()); + assert!(offering + .matches_search("pricing.on_demand.hour < 0.05") + .is_empty()); + assert!(offering + .matches_search("pricing.on_demand.hour <= 0.01") + .is_empty()); + assert!(!offering + .matches_search("pricing.on_demand.hour >= 0.05") + .is_empty()); + assert!(!offering + .matches_search("pricing.on_demand.hour >= 0.15") + .is_empty()); + assert!(offering + .matches_search("pricing.on_demand.hour > 0.15") + .is_empty()); + assert!(offering + .matches_search("pricing.on_demand.hour > 0.5") + .is_empty()); + assert!(!offering.matches_search("type=memory-optimized").is_empty()); + assert!(offering.matches_search("nonexistent=value").is_empty()); } #[test]