Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"contrib",
"multisig-wallet",
"multisig_transfer",
"opsce",
]

[workspace.dependencies]
Expand Down
14 changes: 14 additions & 0 deletions contracts/opsce/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "opsce"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["lib", "cdylib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
38 changes: 38 additions & 0 deletions contracts/opsce/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Shared contract error type for the opsce crate.
//!
//! All modules in the crate (provider rating, KYC verification, ...) report
//! failures through this single enum so callers and tests have a uniform
//! error surface.

use soroban_sdk::contracterror;

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum ContractError {
// -- Lifecycle --
NotInitialized = 1,
AlreadyInitialized = 2,
Unauthorized = 3,

// -- Provider rating --
/// Rating is outside the allowed 1..=5 range.
InvalidRating = 4,
/// The maintenance record has already been rated.
AlreadyRated = 5,
/// Maintenance record id was not found.
RecordNotFound = 6,
/// Maintenance record exists but is not yet marked complete.
RecordNotComplete = 7,
/// Provider profile is not registered.
ProviderNotFound = 8,

// -- KYC verification --
/// The caller is not registered as a KYC oracle.
NotOracle = 20,
/// `submit_kyc_result` was called with a status that is not Approved or
/// Rejected.
InvalidKycStatus = 21,
/// The address has no Approved (or non-expired) KYC record.
KycNotApproved = 22,
}
188 changes: 188 additions & 0 deletions contracts/opsce/src/kyc_verification.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! KYC verification module
//!
//! Implements the on-chain KYC workflow:
//!
//! - Whitelisted oracles submit results via [`submit_kyc_result`].
//! - Asset-transfer style functions can call [`require_kyc`] as a guard.
//! - Approval expires after the stored `expiry` ledger timestamp; once
//! expired, [`get_kyc_status`] reports `Expired` and [`require_kyc`]
//! rejects.

use soroban_sdk::{contracttype, Address, Env, Symbol};

use crate::error::ContractError;
use crate::provider_rating::read_admin;

/// Lifecycle of a KYC record.
///
/// `Expired` is a derived state surfaced by `get_kyc_status` when an
/// `Approved` record's expiry has passed; it cannot be submitted directly.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum KycStatus {
Pending,
Approved,
Rejected,
Expired,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KycRecord {
pub address: Address,
pub status: KycStatus,
/// Ledger timestamp after which the KYC approval is no longer valid.
pub expiry: u64,
pub updated_at: u64,
}

/// Read model returned by `get_kyc_status`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KycStatusInfo {
pub status: KycStatus,
pub expiry: u64,
}

#[contracttype]
pub enum KycDataKey {
/// Stores `bool` true when an address is whitelisted as a KYC oracle.
Oracle(Address),
/// Stores the `KycRecord` for an address.
Record(Address),
}

/// Whitelist a KYC oracle. Admin only.
pub fn add_kyc_oracle(env: &Env, oracle: Address) -> Result<(), ContractError> {
let admin = read_admin(env)?;
admin.require_auth();
env.storage()
.persistent()
.set(&KycDataKey::Oracle(oracle), &true);
Ok(())
}

/// Remove a KYC oracle from the whitelist. Admin only.
pub fn remove_kyc_oracle(env: &Env, oracle: Address) -> Result<(), ContractError> {
let admin = read_admin(env)?;
admin.require_auth();
env.storage()
.persistent()
.set(&KycDataKey::Oracle(oracle), &false);
Ok(())
}

pub fn is_kyc_oracle(env: &Env, oracle: Address) -> bool {
env.storage()
.persistent()
.get(&KycDataKey::Oracle(oracle))
.unwrap_or(false)
}

/// Submit the outcome of an off-chain KYC check.
///
/// `oracle` must be whitelisted via [`add_kyc_oracle`] and must authorize the
/// call. `status` must be either `Approved` or `Rejected`; all other variants
/// return `Err(ContractError::InvalidKycStatus)`.
pub fn submit_kyc_result(
env: &Env,
oracle: Address,
address: Address,
status: KycStatus,
expiry: u64,
) -> Result<(), ContractError> {
oracle.require_auth();

// Reject any caller that is not on the oracle whitelist.
let is_oracle: bool = env
.storage()
.persistent()
.get(&KycDataKey::Oracle(oracle.clone()))
.unwrap_or(false);
if !is_oracle {
return Err(ContractError::NotOracle);
}

// Only Approved and Rejected may be submitted.
match status {
KycStatus::Approved | KycStatus::Rejected => {}
_ => return Err(ContractError::InvalidKycStatus),
}

let record = KycRecord {
address: address.clone(),
status: status.clone(),
expiry,
updated_at: env.ledger().timestamp(),
};
env.storage()
.persistent()
.set(&KycDataKey::Record(address.clone()), &record);

// Emit the appropriate event.
match status {
KycStatus::Approved => {
env.events()
.publish((Symbol::new(env, "kyc_approved"), address), expiry);
}
KycStatus::Rejected => {
env.events()
.publish((Symbol::new(env, "kyc_rejected"), address), expiry);
}
_ => {}
}

Ok(())
}

/// Guard helper for transfer-style functions.
///
/// Returns `Ok(())` only when the address has an `Approved` KYC record whose
/// expiry timestamp is still in the future. Otherwise returns
/// `Err(ContractError::KycNotApproved)`.
pub fn require_kyc(env: &Env, address: Address) -> Result<(), ContractError> {
let record: KycRecord = env
.storage()
.persistent()
.get(&KycDataKey::Record(address))
.ok_or(ContractError::KycNotApproved)?;

match record.status {
KycStatus::Approved => {
if env.ledger().timestamp() > record.expiry {
Err(ContractError::KycNotApproved)
} else {
Ok(())
}
}
_ => Err(ContractError::KycNotApproved),
}
}

/// Returns the current effective status and expiry timestamp for an address.
/// An approved record whose expiry has passed is reported as `Expired`.
/// Unknown addresses return `Pending` with expiry `0`.
pub fn get_kyc_status(env: &Env, address: Address) -> KycStatusInfo {
match env
.storage()
.persistent()
.get::<_, KycRecord>(&KycDataKey::Record(address))
{
Some(record) => {
let now = env.ledger().timestamp();
let status = if matches!(record.status, KycStatus::Approved) && now > record.expiry {
KycStatus::Expired
} else {
record.status
};
KycStatusInfo {
status,
expiry: record.expiry,
}
}
None => KycStatusInfo {
status: KycStatus::Pending,
expiry: 0,
},
}
}
97 changes: 97 additions & 0 deletions contracts/opsce/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#![no_std]
#![allow(clippy::too_many_arguments)]

pub mod error;
pub mod kyc_verification;
pub mod provider_rating;

pub use error::ContractError;
pub use kyc_verification::{KycDataKey, KycRecord, KycStatus, KycStatusInfo};
pub use provider_rating::{
DataKey, MaintenanceRecord, ProviderProfile, ProviderRating, Review,
};

use soroban_sdk::{contract, contractimpl, Address, Env, String};

#[contract]
pub struct OpsceContract;

#[contractimpl]
impl OpsceContract {
// ---- Lifecycle ----

pub fn init(env: Env, admin: Address) -> Result<(), ContractError> {
provider_rating::init(&env, admin)
}

// ---- Provider rating ----

pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> {
provider_rating::register_provider(&env, provider)
}

pub fn record_completed_maintenance(
env: Env,
record_id: u64,
asset_id: u64,
owner: Address,
provider: Address,
) -> Result<(), ContractError> {
provider_rating::record_completed_maintenance(&env, record_id, asset_id, owner, provider)
}

pub fn rate_provider(
env: Env,
record_id: u64,
rating: u32,
comment: String,
) -> Result<(), ContractError> {
provider_rating::rate_provider(&env, record_id, rating, comment)
}

pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating {
provider_rating::get_provider_rating(&env, provider_address)
}

pub fn get_review(env: Env, record_id: u64) -> Option<Review> {
provider_rating::get_review(&env, record_id)
}

// ---- KYC verification ----

pub fn add_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> {
kyc_verification::add_kyc_oracle(&env, oracle)
}

pub fn remove_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> {
kyc_verification::remove_kyc_oracle(&env, oracle)
}

pub fn is_kyc_oracle(env: Env, oracle: Address) -> bool {
kyc_verification::is_kyc_oracle(&env, oracle)
}

pub fn submit_kyc_result(
env: Env,
oracle: Address,
address: Address,
status: KycStatus,
expiry: u64,
) -> Result<(), ContractError> {
kyc_verification::submit_kyc_result(&env, oracle, address, status, expiry)
}

pub fn require_kyc(env: Env, address: Address) -> Result<(), ContractError> {
kyc_verification::require_kyc(&env, address)
}

pub fn get_kyc_status(env: Env, address: Address) -> KycStatusInfo {
kyc_verification::get_kyc_status(&env, address)
}
}

#[cfg(test)]
mod tests_provider_rating;

#[cfg(test)]
mod tests_kyc;
Loading
Loading