diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 590c7ed..34cf5c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,10 +30,13 @@ jobs: contracts/target/ key: ${{ runner.os }}-cargo-${{ hashFiles('contracts/Cargo.lock') }} + - name: Install rustfmt and clippy + run: rustup component add rustfmt clippy + - name: Install System Dependencies run: | sudo apt-get update - sudo apt-get install -y libdbus-1-dev pkg-config libudev-dev + sudo apt-get install -y libdbus-1-dev pkg-config libudev-dev binaryen - name: Install Soroban CLI run: | @@ -41,15 +44,28 @@ jobs: cargo install --locked soroban-cli fi + - name: Check formatting + run: | + cd contracts + cargo fmt --check + + - name: Clippy (zero warnings) + run: | + cd contracts + cargo clippy -- -D warnings + - name: Contract Compilation run: | cd contracts cargo build --target wasm32-unknown-unknown --release + - name: Optimize and check WASM binary sizes + run: make check-size + - name: Contract Tests run: | cd contracts - cargo test + cargo test --workspace - name: Install cargo-llvm-cov run: cargo install cargo-llvm-cov diff --git a/.github/workflows/deploy-testnet.yml b/.github/workflows/deploy-testnet.yml new file mode 100644 index 0000000..42f30ab --- /dev/null +++ b/.github/workflows/deploy-testnet.yml @@ -0,0 +1,87 @@ +name: Deploy to Testnet + +on: + push: + branches: [main] + +jobs: + deploy: + name: Build, Optimize, and Deploy Contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + contracts/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('contracts/Cargo.lock') }} + + - name: Install System Dependencies + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config libudev-dev binaryen + + - name: Install Stellar CLI + run: cargo install --locked stellar-cli + + - name: Build optimized WASM binaries + run: | + cd contracts + cargo build --target wasm32-unknown-unknown --release + for wasm in target/wasm32-unknown-unknown/release/*.wasm; do + wasm-opt -O4 "$wasm" -o "$wasm" + done + + - name: Deploy changed contracts to Stellar testnet + env: + STELLAR_SECRET_KEY: ${{ secrets.STELLAR_SECRET_KEY }} + NETWORK: testnet + RPC_URL: https://soroban-testnet.stellar.org:443 + NETWORK_PASSPHRASE: "Test SDF Network ; September 2015" + run: | + stellar network add \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + "$NETWORK" + + printf '%s' "$STELLAR_SECRET_KEY" | stellar keys generate deployer --secret-key + + SUMMARY="## Testnet Deployment Summary\n\n| Contract | Contract ID |\n|---|---|\n" + for wasm in contracts/target/wasm32-unknown-unknown/release/*.wasm; do + name=$(basename "$wasm" .wasm) + contract_id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source deployer \ + --network "$NETWORK" 2>&1 | tail -1) + echo "Deployed $name: $contract_id" + SUMMARY="$SUMMARY| $name | $contract_id |\n" + done + echo -e "$SUMMARY" >> "$GITHUB_STEP_SUMMARY" + + - name: Post deployment summary as PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const summary = process.env.GITHUB_STEP_SUMMARY + ? fs.readFileSync(process.env.GITHUB_STEP_SUMMARY, 'utf8') + : 'Deployment complete.'; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: summary, + }); diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..d4a0fba --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,21 @@ +name: Security Audit + +on: + schedule: + - cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC + workflow_dispatch: + +jobs: + cargo-audit: + name: cargo audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install cargo-audit + run: cargo install --locked cargo-audit + + - name: Run cargo audit + run: | + cd contracts + cargo audit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9e15c33 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +WASM_DIR := contracts/target/wasm32-unknown-unknown/release +# Maximum allowed WASM binary size in bytes (500 KB) +MAX_WASM_SIZE := 512000 + +.PHONY: build optimize check-size test fmt clippy audit + +build: + cd contracts && cargo build --target wasm32-unknown-unknown --release + +optimize: build + @command -v wasm-opt >/dev/null 2>&1 || { echo "wasm-opt not found. Install binaryen: https://github.com/WebAssembly/binaryen"; exit 1; } + @for wasm in $(WASM_DIR)/*.wasm; do \ + echo "Optimizing $$wasm ..."; \ + wasm-opt -O4 "$$wasm" -o "$$wasm"; \ + done + @echo "Optimization complete." + +check-size: optimize + @echo "Checking WASM binary sizes (limit: $(MAX_WASM_SIZE) bytes)..." + @failed=0; \ + for wasm in $(WASM_DIR)/*.wasm; do \ + size=$$(wc -c < "$$wasm"); \ + name=$$(basename "$$wasm"); \ + echo " $$name: $$size bytes"; \ + if [ "$$size" -gt "$(MAX_WASM_SIZE)" ]; then \ + echo " FAIL: $$name exceeds limit ($$size > $(MAX_WASM_SIZE))"; \ + failed=1; \ + fi; \ + done; \ + if [ "$$failed" -eq 1 ]; then exit 1; fi; \ + echo "All binaries within size limit." + +test: + cd contracts && cargo test --workspace + +fmt: + cd contracts && cargo fmt --check + +clippy: + cd contracts && cargo clippy -- -D warnings + +audit: + cd contracts && cargo audit diff --git a/contracts/src/financial_records/lib.rs b/contracts/src/financial_records/lib.rs new file mode 100644 index 0000000..eb1d945 --- /dev/null +++ b/contracts/src/financial_records/lib.rs @@ -0,0 +1,97 @@ +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, Symbol}; + +/// A payment record stored in the financial ledger. +#[contracttype] +#[derive(Clone)] +pub struct PaymentRecord { + pub payment_id: u64, + pub payer: Address, + pub payee: Address, + pub amount: i128, + pub recorded_at: u64, +} + +// ── Storage helpers ────────────────────────────────────────────────────────── + +fn payment_key(payment_id: u64) -> (Symbol, u64) { + (symbol_short!("payment"), payment_id) +} + +fn next_payment_key() -> Symbol { + symbol_short!("nxt_pay") +} + +// ── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct FinancialRecordsContract; + +#[contractimpl] +impl FinancialRecordsContract { + /// Record a new payment. Returns the assigned payment_id. + pub fn record_payment( + env: Env, + payer: Address, + payee: Address, + amount: i128, + recorded_at: u64, + ) -> u64 { + payer.require_auth(); + assert!(amount > 0, "amount must be positive"); + + let payment_id: u64 = env + .storage() + .instance() + .get(&next_payment_key()) + .unwrap_or(1u64); + env.storage() + .instance() + .set(&next_payment_key(), &(payment_id + 1)); + + let record = PaymentRecord { + payment_id, + payer, + payee, + amount, + recorded_at, + }; + env.storage() + .persistent() + .set(&payment_key(payment_id), &record); + payment_id + } + + /// Retrieve a payment record by id. + pub fn get_payment(env: Env, payment_id: u64) -> PaymentRecord { + env.storage() + .persistent() + .get(&payment_key(payment_id)) + .expect("payment not found") + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + #[test] + fn test_record_and_retrieve_payment() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, FinancialRecordsContract); + let client = FinancialRecordsContractClient::new(&env, &id); + + let payer = Address::generate(&env); + let payee = Address::generate(&env); + + let payment_id = client.record_payment(&payer, &payee, &500i128, &1000u64); + assert_eq!(payment_id, 1); + + let record = client.get_payment(&payment_id); + assert_eq!(record.amount, 500); + assert_eq!(record.payer, payer); + } +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index cdaa9d9..804c384 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -49,3 +49,12 @@ pub mod admin; #[path = "booking_receipt/lib.rs"] pub mod booking_receipt; + +#[path = "nutrition_care/lib.rs"] +pub mod nutrition_care; + +#[path = "medical_claims/lib.rs"] +pub mod medical_claims; + +#[path = "financial_records/lib.rs"] +pub mod financial_records; diff --git a/contracts/src/medical_claims/lib.rs b/contracts/src/medical_claims/lib.rs new file mode 100644 index 0000000..a39ab37 --- /dev/null +++ b/contracts/src/medical_claims/lib.rs @@ -0,0 +1,260 @@ +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec}; + +/// Reconciliation status of a claim. +#[contracttype] +#[derive(Clone, PartialEq)] +pub enum ReconciliationStatus { + Pending, + PartiallyPaid, + FullyReconciled, + Disputed, +} + +/// A medical claim submitted by a provider against an insurer. +#[contracttype] +#[derive(Clone)] +pub struct MedicalClaim { + pub claim_id: u64, + pub provider: Address, + pub insurer: Address, + pub claim_amount: i128, + pub paid_amount: i128, + pub outstanding: i128, + pub status: ReconciliationStatus, + pub submitted_at: u64, +} + +// ── Storage helpers ────────────────────────────────────────────────────────── + +fn claim_key(claim_id: u64) -> (Symbol, u64) { + (symbol_short!("claim"), claim_id) +} + +fn next_claim_key() -> Symbol { + symbol_short!("nxt_clm") +} + +// ── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct MedicalClaimsContract; + +#[contractimpl] +impl MedicalClaimsContract { + /// Submit a new claim. Returns the assigned claim_id. + pub fn submit_claim( + env: Env, + provider: Address, + insurer: Address, + claim_amount: i128, + submitted_at: u64, + ) -> u64 { + provider.require_auth(); + assert!(claim_amount > 0, "claim_amount must be positive"); + + let claim_id: u64 = env + .storage() + .instance() + .get(&next_claim_key()) + .unwrap_or(1u64); + env.storage() + .instance() + .set(&next_claim_key(), &(claim_id + 1)); + + let claim = MedicalClaim { + claim_id, + provider, + insurer, + claim_amount, + paid_amount: 0, + outstanding: claim_amount, + status: ReconciliationStatus::Pending, + submitted_at, + }; + env.storage().persistent().set(&claim_key(claim_id), &claim); + claim_id + } + + /// Apply a payment to a claim (called by reconcile_claim). + /// + /// Updates paid_amount, outstanding, and status atomically. + /// Emits a `ClaimReconciled` event. + pub fn reconcile_claim( + env: Env, + insurer: Address, + claim_id: u64, + payment_id: u64, + payment_amount: i128, + ) { + insurer.require_auth(); + assert!(payment_amount > 0, "payment_amount must be positive"); + + let mut claim: MedicalClaim = env + .storage() + .persistent() + .get(&claim_key(claim_id)) + .expect("claim not found"); + + if claim.insurer != insurer { + panic!("unauthorized: not the claim insurer"); + } + + // Transactional update: both fields change together or neither does. + claim.paid_amount += payment_amount; + claim.outstanding = (claim.claim_amount - claim.paid_amount).max(0); + claim.status = if claim.outstanding == 0 { + ReconciliationStatus::FullyReconciled + } else { + ReconciliationStatus::PartiallyPaid + }; + + env.storage().persistent().set(&claim_key(claim_id), &claim); + + // Emit ClaimReconciled event. + // topics = ["ClaimRecon", claim_id, payment_id] + // data = (claim_amount, payment_amount, outstanding) + env.events().publish( + (symbol_short!("ClaimRcn"), claim_id, payment_id), + (claim.claim_amount, payment_amount, claim.outstanding), + ); + } + + /// Mark a claim as Disputed. + pub fn dispute_claim(env: Env, insurer: Address, claim_id: u64) { + insurer.require_auth(); + let mut claim: MedicalClaim = env + .storage() + .persistent() + .get(&claim_key(claim_id)) + .expect("claim not found"); + if claim.insurer != insurer { + panic!("unauthorized"); + } + claim.status = ReconciliationStatus::Disputed; + env.storage().persistent().set(&claim_key(claim_id), &claim); + } + + /// Return all unreconciled claims for an insurer that are older than + /// `threshold_seconds` relative to `current_time`. + pub fn get_unreconciled_claims( + env: Env, + insurer: Address, + current_time: u64, + threshold_seconds: u64, + ) -> Vec { + let total: u64 = env + .storage() + .instance() + .get(&next_claim_key()) + .unwrap_or(1u64); + + let mut result: Vec = Vec::new(&env); + for id in 1..total { + if let Some(claim) = env + .storage() + .persistent() + .get::<(Symbol, u64), MedicalClaim>(&claim_key(id)) + { + let is_unreconciled = matches!( + claim.status, + ReconciliationStatus::Pending | ReconciliationStatus::PartiallyPaid + ); + let is_old = current_time.saturating_sub(claim.submitted_at) >= threshold_seconds; + if claim.insurer == insurer && is_unreconciled && is_old { + result.push_back(claim); + } + } + } + result + } + + /// Retrieve a single claim by id. + pub fn get_claim(env: Env, claim_id: u64) -> MedicalClaim { + env.storage() + .persistent() + .get(&claim_key(claim_id)) + .expect("claim not found") + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + fn setup() -> (Env, MedicalClaimsContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, MedicalClaimsContract); + let client = MedicalClaimsContractClient::new(&env, &id); + (env, client) + } + + #[test] + fn test_submit_and_full_reconciliation() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let insurer = Address::generate(&env); + + let claim_id = client.submit_claim(&provider, &insurer, &1000i128, &100u64); + assert_eq!(claim_id, 1); + + client.reconcile_claim(&insurer, &claim_id, &42u64, &1000i128); + + let claim = client.get_claim(&claim_id); + assert_eq!(claim.paid_amount, 1000); + assert_eq!(claim.outstanding, 0); + assert_eq!(claim.status, ReconciliationStatus::FullyReconciled); + } + + #[test] + fn test_partial_payment_tracking() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let insurer = Address::generate(&env); + + let claim_id = client.submit_claim(&provider, &insurer, &1000i128, &100u64); + client.reconcile_claim(&insurer, &claim_id, &1u64, &400i128); + + let claim = client.get_claim(&claim_id); + assert_eq!(claim.paid_amount, 400); + assert_eq!(claim.outstanding, 600); + assert_eq!(claim.status, ReconciliationStatus::PartiallyPaid); + } + + #[test] + fn test_get_unreconciled_claims() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let insurer = Address::generate(&env); + + // Two old pending claims, one recent, one fully reconciled. + client.submit_claim(&provider, &insurer, &500i128, &100u64); + client.submit_claim(&provider, &insurer, &300i128, &200u64); + let recent_id = client.submit_claim(&provider, &insurer, &200i128, &900u64); + let reconciled_id = client.submit_claim(&provider, &insurer, &100i128, &50u64); + client.reconcile_claim(&insurer, &reconciled_id, &99u64, &100i128); + + let current_time = 1000u64; + let threshold = 500u64; // older than 500s + + let unreconciled = client.get_unreconciled_claims(&insurer, ¤t_time, &threshold); + // claim at t=100 (age 900) and t=200 (age 800) qualify; t=900 (age 100) does not. + assert_eq!(unreconciled.len(), 2); + let _ = recent_id; // suppress unused warning + } + + #[test] + #[should_panic(expected = "unauthorized")] + fn test_wrong_insurer_cannot_reconcile() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let insurer = Address::generate(&env); + let other = Address::generate(&env); + + let claim_id = client.submit_claim(&provider, &insurer, &500i128, &100u64); + client.reconcile_claim(&other, &claim_id, &1u64, &500i128); + } +} diff --git a/contracts/src/nutrition_care/lib.rs b/contracts/src/nutrition_care/lib.rs new file mode 100644 index 0000000..77ef346 --- /dev/null +++ b/contracts/src/nutrition_care/lib.rs @@ -0,0 +1,237 @@ +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec}; + +/// A single outcome measurement linked to a care plan version. +#[contracttype] +#[derive(Clone)] +pub struct Outcome { + pub plan_id: u64, + pub plan_version: u32, + pub metric: Symbol, // e.g. symbol_short!("weight"), symbol_short!("hba1c") + pub value: i128, // scaled integer (e.g. milligrams, grams×100) + pub measured_at: u64, +} + +/// Minimal care plan record (version tracking only). +#[contracttype] +#[derive(Clone)] +pub struct CarePlan { + pub plan_id: u64, + pub patient: Address, + pub provider: Address, + pub version: u32, +} + +// ── Storage keys ──────────────────────────────────────────────────────────── + +fn plan_key(plan_id: u64) -> (Symbol, u64) { + (symbol_short!("plan"), plan_id) +} + +fn outcomes_key(plan_id: u64) -> (Symbol, u64) { + (symbol_short!("outcomes"), plan_id) +} + +fn next_plan_key() -> Symbol { + symbol_short!("next_plan") +} + +// ── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct NutritionCareContract; + +#[contractimpl] +impl NutritionCareContract { + /// Create a new care plan. Returns the assigned plan_id. + pub fn create_plan(env: Env, provider: Address, patient: Address) -> u64 { + provider.require_auth(); + let plan_id: u64 = env + .storage() + .instance() + .get(&next_plan_key()) + .unwrap_or(1u64); + env.storage() + .instance() + .set(&next_plan_key(), &(plan_id + 1)); + + let plan = CarePlan { + plan_id, + patient, + provider, + version: 1, + }; + env.storage().persistent().set(&plan_key(plan_id), &plan); + plan_id + } + + /// Record a clinical outcome linked to the current version of a care plan. + /// + /// Only the provider who owns the plan may call this. + /// Emits a `NutritionOutcomeRecorded` event. + pub fn link_outcome( + env: Env, + provider: Address, + plan_id: u64, + metric: Symbol, + value: i128, + measured_at: u64, + ) { + provider.require_auth(); + + let plan: CarePlan = env + .storage() + .persistent() + .get(&plan_key(plan_id)) + .expect("plan not found"); + + // Only the plan's provider may record outcomes. + if plan.provider != provider { + panic!("unauthorized: not the plan provider"); + } + + let outcome = Outcome { + plan_id, + plan_version: plan.version, + metric: metric.clone(), + value, + measured_at, + }; + + // Append to the outcomes list for this plan. + let mut outcomes: Vec = env + .storage() + .persistent() + .get(&outcomes_key(plan_id)) + .unwrap_or_else(|| Vec::new(&env)); + outcomes.push_back(outcome); + env.storage() + .persistent() + .set(&outcomes_key(plan_id), &outcomes); + + // Emit event: topics = ["NutrOutcome", plan_id], data = (metric, value, measured_at) + env.events().publish( + (symbol_short!("NutrOut"), plan_id), + (metric, value, measured_at), + ); + } + + /// Return all outcome measurements for a plan, in chronological order + /// (outcomes are appended in order, so the stored Vec is already sorted). + pub fn get_plan_outcomes(env: Env, plan_id: u64) -> Vec { + env.storage() + .persistent() + .get(&outcomes_key(plan_id)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Retrieve a care plan by id. + pub fn get_plan(env: Env, plan_id: u64) -> CarePlan { + env.storage() + .persistent() + .get(&plan_key(plan_id)) + .expect("plan not found") + } + + /// Bump the plan version (e.g. when the dietary prescription changes). + /// Only the plan's provider may do this. + pub fn update_plan_version(env: Env, provider: Address, plan_id: u64) { + provider.require_auth(); + let mut plan: CarePlan = env + .storage() + .persistent() + .get(&plan_key(plan_id)) + .expect("plan not found"); + if plan.provider != provider { + panic!("unauthorized: not the plan provider"); + } + plan.version += 1; + env.storage().persistent().set(&plan_key(plan_id), &plan); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + fn setup() -> (Env, NutritionCareContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, NutritionCareContract); + let client = NutritionCareContractClient::new(&env, &contract_id); + (env, client) + } + + #[test] + fn test_create_plan_and_link_outcome() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let patient = Address::generate(&env); + + let plan_id = client.create_plan(&provider, &patient); + assert_eq!(plan_id, 1); + + client.link_outcome( + &provider, + &plan_id, + &symbol_short!("weight"), + &7500i128, // 75.00 kg × 100 + &1_000_000u64, + ); + + let outcomes = client.get_plan_outcomes(&plan_id); + assert_eq!(outcomes.len(), 1); + let o = outcomes.get(0).unwrap(); + assert_eq!(o.metric, symbol_short!("weight")); + assert_eq!(o.value, 7500); + assert_eq!(o.plan_version, 1); + } + + #[test] + fn test_outcomes_linked_to_plan_version() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let patient = Address::generate(&env); + + let plan_id = client.create_plan(&provider, &patient); + + client.link_outcome(&provider, &plan_id, &symbol_short!("hba1c"), &65i128, &100u64); + client.update_plan_version(&provider, &plan_id); + client.link_outcome(&provider, &plan_id, &symbol_short!("hba1c"), &58i128, &200u64); + + let outcomes = client.get_plan_outcomes(&plan_id); + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes.get(0).unwrap().plan_version, 1); + assert_eq!(outcomes.get(1).unwrap().plan_version, 2); + } + + #[test] + fn test_chronological_order() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let patient = Address::generate(&env); + let plan_id = client.create_plan(&provider, &patient); + + for t in [100u64, 200, 300] { + client.link_outcome(&provider, &plan_id, &symbol_short!("weight"), &(t as i128), &t); + } + + let outcomes = client.get_plan_outcomes(&plan_id); + assert_eq!(outcomes.len(), 3); + assert!(outcomes.get(0).unwrap().measured_at < outcomes.get(1).unwrap().measured_at); + assert!(outcomes.get(1).unwrap().measured_at < outcomes.get(2).unwrap().measured_at); + } + + #[test] + #[should_panic(expected = "unauthorized")] + fn test_wrong_provider_cannot_record() { + let (env, client) = setup(); + let provider = Address::generate(&env); + let other = Address::generate(&env); + let patient = Address::generate(&env); + let plan_id = client.create_plan(&provider, &patient); + client.link_outcome(&other, &plan_id, &symbol_short!("weight"), &100i128, &1u64); + } +}