From b3a95df8fffedd18b95a5691688171000021a137 Mon Sep 17 00:00:00 2001 From: khaadish Date: Sat, 17 Oct 2026 01:57:12 +0100 Subject: [PATCH 1/2] feat: audit reconciliation, branches, transfer history, specific errors Closes #1497 Closes #1498 Closes #1499 Closes #1500 - assetsup/audit: document the reconciliation contract between the on-chain audit log (the source of truth) and the backend `audit-logs` service, including the composite identity that makes replay idempotent and how divergence is resolved (#1497). - assetsup/branch: add `register_branch` and `assign_asset_to_branch`. The latter rejects an unknown branch (`BranchNotFound`) and an unknown asset (`AssetNotFound`), so an asset can never be pointed at a stale or misspelled branch id. Adds `get_branch`/`get_branch_assets` reads and the `branch_registered` / `asset_assigned_to_branch` events (#1498). - assetsup/asset: append every ownership change to a per-asset `TransferHistory` and expose `get_asset_transfer_history`, so the full chain of custody is queryable on-chain and not just the current owner (#1499). - assetsup/error + insurance: replace reused generic variants with precise ones (`PolicyNotFound`, `ClaimNotFound`, `InvalidPolicyStatus`, `InvalidClaimStatus`, `InvalidPolicyTerms`, `InvalidClaimAmount`, `PolicyAlreadyExists`, `ClaimAlreadyExists`) so integrators can tell failure causes apart (#1500). Codes 187 onward are allocated, reserving 187 for the insurance expiry reminder from #1491. --- contracts/EVENTS.md | 7 ++ contracts/assetsup/README.md | 35 +++++-- contracts/assetsup/src/asset.rs | 16 +++ contracts/assetsup/src/audit.rs | 37 +++++++ contracts/assetsup/src/error.rs | 26 +++++ contracts/assetsup/src/events.rs | 46 +++++++++ contracts/assetsup/src/insurance.rs | 74 +++++++------- contracts/assetsup/src/lib.rs | 130 ++++++++++++++++++++++++- contracts/assetsup/src/tests/asset.rs | 85 ++++++++++++++++ contracts/assetsup/src/tests/events.rs | 33 +++++++ 10 files changed, 444 insertions(+), 45 deletions(-) diff --git a/contracts/EVENTS.md b/contracts/EVENTS.md index 920bb5216..3273c3a92 100644 --- a/contracts/EVENTS.md +++ b/contracts/EVENTS.md @@ -72,6 +72,13 @@ assert_eq!(name, Symbol::new(&env, "asset_registered")); | `contract_paused` | `admin` | `timestamp` | `pause_contract` | | `contract_unpaused` | `admin` | `timestamp` | `unpause_contract` | +### Branches + +| Event (topic 0) | Topic 1 | Data | Emitted by | +|---|---|---|---| +| `branch_registered` | `branch_id` | `admin`, `timestamp` | `register_branch` | +| `asset_assigned_to_branch` | `branch_id`, `asset_id` | `caller`, `timestamp` | `assign_asset_to_branch` | + ### Tokenization | Event | Topic 1 | Data | Emitted by | diff --git a/contracts/assetsup/README.md b/contracts/assetsup/README.md index 87216ded8..79e4f514d 100644 --- a/contracts/assetsup/README.md +++ b/contracts/assetsup/README.md @@ -105,9 +105,28 @@ permanently bricks administration. A two-step transfer is tracked in [SC-48]. > [SC-42]; it is the highest-severity item in the workspace. Reads: `get_asset`, `get_asset_info`, `batch_get_asset_info`, -`get_assets_by_owner`, `check_asset_exists`, `get_total_asset_count`, -`get_admin`, `is_paused`, `is_authorized_registrar`, `get_contract_metadata`, -`get_asset_audit_logs`. +`get_assets_by_owner`, `get_asset_transfer_history`, `check_asset_exists`, +`get_total_asset_count`, `get_admin`, `is_paused`, `is_authorized_registrar`, +`get_contract_metadata`, `get_asset_audit_logs`. + +### Branches + +| Entrypoint | Auth | +|---|---| +| `register_branch` | admin | +| `assign_asset_to_branch` | admin or branch admin | +| `get_branch`, `get_branch_assets` | — | + +`assign_asset_to_branch` rejects an unknown branch (`BranchNotFound`) and an +unknown asset (`AssetNotFound`), so an asset can never be pointed at a stale or +misspelled branch id. + +### Transfer history + +`get_asset_transfer_history` returns the asset's append-only list of ownership +transfers (oldest first), recorded on every `transfer_asset_ownership` call. The +on-chain audit log and its reconciliation with the off-chain `audit-logs` +service are documented in [`src/audit.rs`](src/audit.rs). ### Tokenization @@ -163,6 +182,7 @@ Reads: `get_unclaimed_dividends`, `get_vote_tally`, `has_voted`, | `("admin_chg",)` | `update_admin` | | `("c_pause",)` | `pause_contract` | | `("c_unpause",)` | `unpause_contract` | +| `("branch_registered",)`, `("asset_assigned_to_branch",)` | `register_branch`, `assign_asset_to_branch` | | `("lease_new",)`, `("lease_ret",)`, `("lease_can",)`, `("lease_exp",)` | lease lifecycle | Tokenization, dividends, voting, and detokenization emit events too; see the @@ -171,10 +191,11 @@ for the workspace-wide event catalogue. ## Errors -`Error`, defined in [`src/error.rs`](src/error.rs), codes 1–46, grouped by -concern (registry 1–9, tokenization 10–20, voting 21–25, dividends 26–27, -detokenization 28–29, valuation 30, holders 31, math 32–33, contract state -34–35, validation 36–39, leasing 40–46). +`Error`, defined in [`src/error.rs`](src/error.rs), follows the workspace +allocation in [`../ERRORS.md`](../ERRORS.md): shared codes 1–99 mean the same +thing in every contract, and `assetsup`-specific codes occupy 100–199 (registry +100–119, tokenization 120–139, voting 140–149, dividends 150–159, detokenization +160–169, validation 170–179, leasing and insurance 180–199). Note that most entrypoints return `Result<_, Error>`, but `handle_error` panics with the error instead of returning it in some paths, so callers see a trap diff --git a/contracts/assetsup/src/asset.rs b/contracts/assetsup/src/asset.rs index daee3f8c1..16f98a43b 100644 --- a/contracts/assetsup/src/asset.rs +++ b/contracts/assetsup/src/asset.rs @@ -8,6 +8,8 @@ pub enum DataKey { Asset(BytesN<32>), OwnerRegistry(Address), AssetCounter, + /// Append-only, oldest-first list of ownership transfers for one asset. + TransferHistory(BytesN<32>), } #[contracttype] @@ -36,4 +38,18 @@ pub struct AssetInfo { pub status: AssetStatus, } +/// One entry in an asset's on-chain transfer history. +/// +/// Recorded on every successful [`crate::AssetUpContract::transfer_asset_ownership`] +/// call, oldest first, so integrators can reconstruct the chain of custody +/// without replaying events. `from` is the owner that transferred the asset and +/// `to` is the new owner. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferRecord { + pub from: Address, + pub to: Address, + pub timestamp: u64, +} + // Note: Contract methods implemented in lib.rs diff --git a/contracts/assetsup/src/audit.rs b/contracts/assetsup/src/audit.rs index 8e01ea54c..c3854af82 100644 --- a/contracts/assetsup/src/audit.rs +++ b/contracts/assetsup/src/audit.rs @@ -1,3 +1,40 @@ +//! On-chain audit log and its reconciliation contract with the off-chain +//! `audit-logs` service ([SC-43]). +//! +//! ## What is written on-chain +//! +//! Every significant registry action appends an [`AuditEntry`] to the asset's +//! log. The entry is intentionally minimal and self-describing: +//! +//! | Field | Meaning | Reconciliation key | +//! |---|---|---| +//! | `timestamp` | Ledger close time of the action | Part of the composite key | +//! | `action` | Stable machine token, e.g. `ASSET_REGISTERED` | Part of the composite key | +//! | `actor` | Address that triggered the action | Part of the composite key | +//! | `details` | Human-readable context | Not used for matching | +//! +//! ## Reconciliation model +//! +//! The on-chain log is the **source of truth**. The backend `audit-logs` +//! service keeps a richer record and must reconcile against the chain as +//! follows: +//! +//! 1. Index [`crate::events`] for the same action; each event carries the +//! transaction hash and ledger sequence that the on-chain entry also falls +//! under. Store that transaction hash on the off-chain record. +//! 2. Treat `(asset_id, action, actor, timestamp)` as the composite identity of +//! an entry. Appending is idempotent under this key, so replaying a ledger +//! range never produces duplicates. +//! 3. On divergence, the on-chain entry wins: an off-chain record with no +//! matching entry is an orphan and must be flagged, and an on-chain entry +//! with no off-chain record must be backfilled from the chain. +//! 4. `details` is display-only. Never match or reconcile on it, because its +//! wording may change between contract versions. +//! +//! Actions are append-only and are never mutated or deleted on-chain, so the +//! log may be replayed from genesis at any time to rebuild the off-chain +//! index. + use soroban_sdk::{contracttype, Address, BytesN, Env, String, Vec}; #[contracttype] diff --git a/contracts/assetsup/src/error.rs b/contracts/assetsup/src/error.rs index 2c2cb5221..8be16a124 100644 --- a/contracts/assetsup/src/error.rs +++ b/contracts/assetsup/src/error.rs @@ -150,6 +150,24 @@ pub enum Error { LeaseNotExpired = 185, /// The supplied start and end timestamps are inconsistent. InvalidTimestamps = 186, + // 187 is reserved for the insurance expiry reminder (#1491) so the two + // insurance PRs do not clash. Numbers are never reused, so the gap is kept. + /// No insurance policy is stored under this id. + PolicyNotFound = 188, + /// No insurance claim is stored under this id. + ClaimNotFound = 189, + /// The policy is not in a state that permits this action. + InvalidPolicyStatus = 190, + /// The claim is not in a state that permits this action. + InvalidClaimStatus = 191, + /// The policy terms are unusable (bad coverage, premium, or dates). + InvalidPolicyTerms = 192, + /// The claim amount is zero, negative, or above the policy coverage. + InvalidClaimAmount = 193, + /// A policy with this id already exists. + PolicyAlreadyExists = 194, + /// A claim with this id already exists. + ClaimAlreadyExists = 195, } pub fn handle_error(env: &Env, error: Error) -> ! { @@ -244,6 +262,14 @@ mod tests { Error::LeaseAlreadyStarted as u32, Error::LeaseNotExpired as u32, Error::InvalidTimestamps as u32, + Error::PolicyNotFound as u32, + Error::ClaimNotFound as u32, + Error::InvalidPolicyStatus as u32, + Error::InvalidClaimStatus as u32, + Error::InvalidPolicyTerms as u32, + Error::InvalidClaimAmount as u32, + Error::PolicyAlreadyExists as u32, + Error::ClaimAlreadyExists as u32, ]; for code in codes { diff --git a/contracts/assetsup/src/events.rs b/contracts/assetsup/src/events.rs index 71dec40b0..0dea165f4 100644 --- a/contracts/assetsup/src/events.rs +++ b/contracts/assetsup/src/events.rs @@ -65,6 +65,28 @@ pub struct AssetRetired { pub timestamp: u64, } +/// A branch was registered. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BranchRegistered { + #[topic] + pub branch_id: BytesN<32>, + pub admin: Address, + pub timestamp: u64, +} + +/// An asset was assigned to a branch. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetAssignedToBranch { + #[topic] + pub branch_id: BytesN<32>, + #[topic] + pub asset_id: BytesN<32>, + pub caller: Address, + pub timestamp: u64, +} + /// An admin transfer was nominated. The role does **not** move until the /// proposed address accepts. #[contractevent] @@ -407,6 +429,30 @@ pub fn asset_retired(env: &Env, asset_id: &BytesN<32>, caller: &Address) { .publish(env); } +pub fn branch_registered(env: &Env, branch_id: &BytesN<32>, admin: &Address) { + BranchRegistered { + branch_id: branch_id.clone(), + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_assigned_to_branch( + env: &Env, + branch_id: &BytesN<32>, + asset_id: &BytesN<32>, + caller: &Address, +) { + AssetAssignedToBranch { + branch_id: branch_id.clone(), + asset_id: asset_id.clone(), + caller: caller.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + pub fn admin_proposed(env: &Env, current_admin: &Address, proposed_admin: &Address) { AdminProposed { proposed_admin: proposed_admin.clone(), diff --git a/contracts/assetsup/src/insurance.rs b/contracts/assetsup/src/insurance.rs index 58d5d5319..6f27bede5 100644 --- a/contracts/assetsup/src/insurance.rs +++ b/contracts/assetsup/src/insurance.rs @@ -88,23 +88,23 @@ pub enum DataKey { pub fn create_policy(env: Env, policy: InsurancePolicy) -> Result<(), Error> { // Validate coverage and deductible if policy.coverage_amount <= 0 || policy.deductible >= policy.coverage_amount { - return Err(Error::InvalidPayment); + return Err(Error::InvalidPolicyTerms); } // Validate premium if policy.premium <= 0 { - return Err(Error::InvalidPayment); + return Err(Error::InvalidPolicyTerms); } // Validate dates: start_date must be before end_date if policy.start_date >= policy.end_date { - return Err(Error::InvalidPayment); + return Err(Error::InvalidPolicyTerms); } // Validate that start_date is not in the past (allow current timestamp) let current_time = env.ledger().timestamp(); if policy.start_date < current_time { - return Err(Error::InvalidPayment); + return Err(Error::InvalidPolicyTerms); } let key = DataKey::Policy(policy.policy_id.clone()); @@ -112,7 +112,7 @@ pub fn create_policy(env: Env, policy: InsurancePolicy) -> Result<(), Error> { // Check if policy already exists if store.has(&key) { - return Err(Error::AssetAlreadyExists); + return Err(Error::PolicyAlreadyExists); } // Store the policy @@ -144,7 +144,7 @@ pub fn cancel_policy(env: Env, policy_id: BytesN<32>, caller: Address) -> Result let store = env.storage().persistent(); let key = DataKey::Policy(policy_id.clone()); - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::PolicyNotFound)?; // Only holder or insurer can cancel if caller != policy.holder && caller != policy.insurer { @@ -153,7 +153,7 @@ pub fn cancel_policy(env: Env, policy_id: BytesN<32>, caller: Address) -> Result // Validate status transition: only Active or Suspended policies can be cancelled if policy.status != PolicyStatus::Active && policy.status != PolicyStatus::Suspended { - return Err(Error::Unauthorized); + return Err(Error::InvalidPolicyStatus); } policy.status = PolicyStatus::Cancelled; @@ -177,7 +177,7 @@ pub fn suspend_policy(env: Env, policy_id: BytesN<32>, insurer: Address) -> Resu let store = env.storage().persistent(); let key = DataKey::Policy(policy_id.clone()); - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::PolicyNotFound)?; // Only insurer can suspend if insurer != policy.insurer { @@ -186,7 +186,7 @@ pub fn suspend_policy(env: Env, policy_id: BytesN<32>, insurer: Address) -> Resu // Validate status transition: only Active policies can be suspended if policy.status != PolicyStatus::Active { - return Err(Error::Unauthorized); + return Err(Error::InvalidPolicyStatus); } policy.status = PolicyStatus::Suspended; @@ -201,18 +201,18 @@ pub fn expire_policy(env: Env, policy_id: BytesN<32>) -> Result<(), Error> { let store = env.storage().persistent(); let key = DataKey::Policy(policy_id.clone()); - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::PolicyNotFound)?; let current_time = env.ledger().timestamp(); // Require that end_date has passed if policy.end_date >= current_time { - return Err(Error::Unauthorized); + return Err(Error::InvalidPolicyStatus); } // Validate status transition: only Active or Suspended policies can expire if policy.status != PolicyStatus::Active && policy.status != PolicyStatus::Suspended { - return Err(Error::Unauthorized); + return Err(Error::InvalidPolicyStatus); } policy.status = PolicyStatus::Expired; @@ -233,7 +233,7 @@ pub fn renew_policy( let store = env.storage().persistent(); let key = DataKey::Policy(policy_id.clone()); - let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::AssetNotFound)?; + let mut policy: InsurancePolicy = store.get(&key).ok_or(Error::PolicyNotFound)?; // Only insurer can renew if insurer != policy.insurer { @@ -242,19 +242,19 @@ pub fn renew_policy( // Validate status transition: only Active or Expired policies can be renewed if policy.status != PolicyStatus::Active && policy.status != PolicyStatus::Expired { - return Err(Error::Unauthorized); + return Err(Error::InvalidPolicyStatus); } let current_time = env.ledger().timestamp(); // Validate new end date is in the future if new_end_date <= current_time { - return Err(Error::InvalidPayment); + return Err(Error::InvalidPolicyTerms); } // Validate new premium is positive if new_premium <= 0 { - return Err(Error::InvalidPayment); + return Err(Error::InvalidPolicyTerms); } // Update policy @@ -295,25 +295,25 @@ pub fn file_insurance_claim(env: Env, claim: InsuranceClaim) -> Result<(), Error let policy_key = DataKey::Policy(claim.policy_id.clone()); // Verify policy exists and is Active - let policy: InsurancePolicy = store.get(&policy_key).ok_or(Error::AssetNotFound)?; + let policy: InsurancePolicy = store.get(&policy_key).ok_or(Error::PolicyNotFound)?; if policy.status != PolicyStatus::Active { - return Err(Error::Unauthorized); + return Err(Error::InvalidPolicyStatus); } // Verify claim amount is positive if claim.amount <= 0 { - return Err(Error::InvalidPayment); + return Err(Error::InvalidClaimAmount); } // Verify claim doesn't already exist let claim_key = DataKey::Claim(claim.claim_id.clone()); if store.has(&claim_key) { - return Err(Error::AssetAlreadyExists); + return Err(Error::ClaimAlreadyExists); } // Verify claim status is Submitted if claim.status != ClaimStatus::Submitted { - return Err(Error::Unauthorized); + return Err(Error::InvalidClaimStatus); } // Store the claim @@ -341,19 +341,19 @@ pub fn mark_insurance_claim_under_review( let store = env.storage().persistent(); let claim_key = DataKey::Claim(claim_id.clone()); - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::ClaimNotFound)?; // Verify insurer is authorized let policy: InsurancePolicy = store .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; + .ok_or(Error::PolicyNotFound)?; if insurer != policy.insurer { return Err(Error::Unauthorized); } // Validate status transition: only Submitted claims can move to UnderReview if claim.status != ClaimStatus::Submitted { - return Err(Error::Unauthorized); + return Err(Error::InvalidClaimStatus); } claim.status = ClaimStatus::UnderReview; @@ -375,29 +375,29 @@ pub fn approve_insurance_claim( let store = env.storage().persistent(); let claim_key = DataKey::Claim(claim_id.clone()); - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::ClaimNotFound)?; // Verify insurer is authorized let policy: InsurancePolicy = store .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; + .ok_or(Error::PolicyNotFound)?; if insurer != policy.insurer { return Err(Error::Unauthorized); } // Validate status transition: only UnderReview claims can be approved if claim.status != ClaimStatus::UnderReview { - return Err(Error::Unauthorized); + return Err(Error::InvalidClaimStatus); } // Validate approved amount if approved_amount <= 0 { - return Err(Error::InvalidPayment); + return Err(Error::InvalidClaimAmount); } // Approved amount cannot exceed coverage amount if approved_amount > policy.coverage_amount { - return Err(Error::InvalidPayment); + return Err(Error::InvalidClaimAmount); } claim.status = ClaimStatus::Approved; @@ -419,19 +419,19 @@ pub fn reject_insurance_claim( let store = env.storage().persistent(); let claim_key = DataKey::Claim(claim_id.clone()); - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::ClaimNotFound)?; // Verify insurer is authorized let policy: InsurancePolicy = store .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; + .ok_or(Error::PolicyNotFound)?; if insurer != policy.insurer { return Err(Error::Unauthorized); } // Validate status transition: only Submitted or UnderReview claims can be rejected if claim.status != ClaimStatus::Submitted && claim.status != ClaimStatus::UnderReview { - return Err(Error::Unauthorized); + return Err(Error::InvalidClaimStatus); } claim.status = ClaimStatus::Rejected; @@ -452,7 +452,7 @@ pub fn dispute_insurance_claim( let store = env.storage().persistent(); let claim_key = DataKey::Claim(claim_id.clone()); - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::ClaimNotFound)?; // Verify claimant is authorized if claimant != claim.claimant { @@ -461,7 +461,7 @@ pub fn dispute_insurance_claim( // Validate status transition: only Rejected claims can be disputed if claim.status != ClaimStatus::Rejected { - return Err(Error::Unauthorized); + return Err(Error::InvalidClaimStatus); } claim.status = ClaimStatus::Disputed; @@ -478,19 +478,19 @@ pub fn pay_insurance_claim(env: Env, claim_id: BytesN<32>, insurer: Address) -> let store = env.storage().persistent(); let claim_key = DataKey::Claim(claim_id.clone()); - let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::AssetNotFound)?; + let mut claim: InsuranceClaim = store.get(&claim_key).ok_or(Error::ClaimNotFound)?; // Verify insurer is authorized let policy: InsurancePolicy = store .get(&DataKey::Policy(claim.policy_id.clone())) - .ok_or(Error::AssetNotFound)?; + .ok_or(Error::PolicyNotFound)?; if insurer != policy.insurer { return Err(Error::Unauthorized); } // Validate status transition: only Approved claims can be paid if claim.status != ClaimStatus::Approved { - return Err(Error::Unauthorized); + return Err(Error::InvalidClaimStatus); } claim.status = ClaimStatus::Paid; diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index b30fdc1b1..d259c8d93 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -424,11 +424,25 @@ impl AssetUpContract { // Update asset asset.owner = new_owner.clone(); - asset.last_transfer_timestamp = env.ledger().timestamp(); + let transferred_at = env.ledger().timestamp(); + asset.last_transfer_timestamp = transferred_at; asset.status = AssetStatus::Transferred; store.set(&key, &asset); ttl::extend_persistent(&env, &key); + // Append to the asset's transfer history so the whole chain of custody + // stays queryable on-chain, not just the current owner. + let history_key = asset::DataKey::TransferHistory(asset_id.clone()); + let mut history: Vec = + store.get(&history_key).unwrap_or_else(|| Vec::new(&env)); + history.push_back(asset::TransferRecord { + from: old_owner.clone(), + to: new_owner.clone(), + timestamp: transferred_at, + }); + store.set(&history_key, &history); + ttl::extend_persistent(&env, &history_key); + // Append audit log audit::append_audit_log( &env, @@ -514,6 +528,120 @@ impl AssetUpContract { } } + /// The asset's full transfer history, oldest first. + /// + /// Each entry records the previous and new owner and the ledger time of the + /// transfer. Returns `AssetNotFound` for an unknown asset so a typo'd id + /// does not look like an asset that was never transferred. + pub fn get_asset_transfer_history( + env: Env, + asset_id: BytesN<32>, + ) -> Result, Error> { + ttl::extend_instance(&env); + + let asset_key = asset::DataKey::Asset(asset_id.clone()); + if !env.storage().persistent().has(&asset_key) { + return Err(Error::AssetNotFound); + } + + let key = asset::DataKey::TransferHistory(asset_id); + let store = env.storage().persistent(); + Ok(store.get(&key).unwrap_or_else(|| Vec::new(&env))) + } + + /// Registers a branch. Admin-gated, and the id must be unused. + pub fn register_branch(env: Env, branch: branch::Branch) -> Result<(), Error> { + ttl::extend_instance(&env); + + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + + let key = branch::DataKey::Branch(branch.id.clone()); + let store = env.storage().persistent(); + if store.has(&key) { + return Err(Error::BranchAlreadyExists); + } + store.set(&key, &branch); + ttl::extend_persistent(&env, &key); + + events::branch_registered(&env, &branch.id, &branch.admin); + + Ok(()) + } + + /// Assigns an asset to a branch. + /// + /// Both the branch and the asset must exist: without the branch check an + /// asset could be pointed at a stale or misspelled branch id and silently + /// disappear from that branch's inventory. Authorized by the contract admin + /// or the branch's own admin. + pub fn assign_asset_to_branch( + env: Env, + branch_id: BytesN<32>, + asset_id: BytesN<32>, + caller: Address, + ) -> Result<(), Error> { + ttl::extend_instance(&env); + + caller.require_auth(); + + if Self::is_paused(env.clone())? { + return Err(Error::ContractPaused); + } + + let branch_key = branch::DataKey::Branch(branch_id.clone()); + let asset_key = asset::DataKey::Asset(asset_id.clone()); + let store = env.storage().persistent(); + + let branch: branch::Branch = store.get(&branch_key).ok_or(Error::BranchNotFound)?; + if !store.has(&asset_key) { + return Err(Error::AssetNotFound); + } + + let admin = Self::get_admin(env.clone())?; + if caller != admin && caller != branch.admin { + return Err(Error::Unauthorized); + } + + let list_key = branch::DataKey::AssetList(branch_id.clone()); + let mut assets: Vec> = + store.get(&list_key).unwrap_or_else(|| Vec::new(&env)); + if !assets.iter().any(|x| x == asset_id) { + assets.push_back(asset_id.clone()); + store.set(&list_key, &assets); + ttl::extend_persistent(&env, &list_key); + } + + events::asset_assigned_to_branch(&env, &branch_id, &asset_id, &caller); + + Ok(()) + } + + /// A branch by id, or `BranchNotFound`. + pub fn get_branch(env: Env, branch_id: BytesN<32>) -> Result { + let key = branch::DataKey::Branch(branch_id); + let store = env.storage().persistent(); + match store.get(&key) { + Some(b) => { + ttl::extend_persistent(&env, &key); + Ok(b) + } + None => Err(Error::BranchNotFound), + } + } + + /// The assets assigned to a branch, oldest first. + pub fn get_branch_assets(env: Env, branch_id: BytesN<32>) -> Result>, Error> { + let _ = Self::get_branch(env.clone(), branch_id.clone())?; + let key = branch::DataKey::AssetList(branch_id); + let store = env.storage().persistent(); + Ok(store.get(&key).unwrap_or_else(|| Vec::new(&env))) + } + pub fn check_asset_exists(env: Env, asset_id: BytesN<32>) -> Result { ttl::extend_instance(&env); diff --git a/contracts/assetsup/src/tests/asset.rs b/contracts/assetsup/src/tests/asset.rs index 1f7c5bb39..59be8229a 100644 --- a/contracts/assetsup/src/tests/asset.rs +++ b/contracts/assetsup/src/tests/asset.rs @@ -338,3 +338,88 @@ fn test_batch_get_asset_info() { let infos = client.batch_get_asset_info(&ids); assert_eq!(infos.len(), 2); } + +#[test] +fn test_transfer_history_records_every_transfer() { + let env = create_env(); + let (admin, user1, user2, user3) = create_mock_addresses(&env); + let client = initialize_contract(&env, &admin); + + let asset_id = generate_asset_id(&env, 1); + let asset = create_test_asset(&env, &user1, asset_id.clone()); + + env.mock_all_auths(); + client.register_asset(&asset, &admin); + + client.transfer_asset_ownership(&asset_id, &user2, &user1); + client.transfer_asset_ownership(&asset_id, &user3, &user2); + + let history = client.get_asset_transfer_history(&asset_id); + assert_eq!(history.len(), 2); + + let first = history.get(0).unwrap(); + assert_eq!(first.from, user1); + assert_eq!(first.to, user2); + + let second = history.get(1).unwrap(); + assert_eq!(second.from, user2); + assert_eq!(second.to, user3); +} + +#[test] +#[should_panic(expected = "Error(Contract, #102)")] +fn test_transfer_history_rejects_unknown_asset() { + let env = create_env(); + let (admin, _, _, _) = create_mock_addresses(&env); + let client = initialize_contract(&env, &admin); + + let asset_id = generate_asset_id(&env, 999); + client.get_asset_transfer_history(&asset_id); +} + +#[test] +fn test_register_branch_and_assign_asset() { + let env = create_env(); + let (admin, user1, _, _) = create_mock_addresses(&env); + let client = initialize_contract(&env, &admin); + + let branch_id = generate_asset_id(&env, 50); + let branch = crate::branch::Branch { + id: branch_id.clone(), + name: String::from_str(&env, "North Depot"), + location: String::from_str(&env, "Lagos"), + admin: admin.clone(), + }; + + let asset_id = generate_asset_id(&env, 1); + let asset = create_test_asset(&env, &user1, asset_id.clone()); + + env.mock_all_auths(); + client.register_branch(&branch); + client.register_asset(&asset, &admin); + client.assign_asset_to_branch(&branch_id, &asset_id, &admin); + + let stored = client.get_branch(&branch_id); + assert_eq!(stored.id, branch_id); + + let assets = client.get_branch_assets(&branch_id); + assert_eq!(assets.len(), 1); + assert_eq!(assets.get(0).unwrap(), asset_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #104)")] +fn test_assign_asset_to_unknown_branch_fails() { + let env = create_env(); + let (admin, user1, _, _) = create_mock_addresses(&env); + let client = initialize_contract(&env, &admin); + + let asset_id = generate_asset_id(&env, 1); + let asset = create_test_asset(&env, &user1, asset_id.clone()); + let missing_branch = generate_asset_id(&env, 777); + + env.mock_all_auths(); + client.register_asset(&asset, &admin); + + client.assign_asset_to_branch(&missing_branch, &asset_id, &admin); +} diff --git a/contracts/assetsup/src/tests/events.rs b/contracts/assetsup/src/tests/events.rs index 93459b427..c188c635d 100644 --- a/contracts/assetsup/src/tests/events.rs +++ b/contracts/assetsup/src/tests/events.rs @@ -173,6 +173,39 @@ fn registrar_changes_are_observable() { assert_eq!(emitted, registrar); } +#[test] +fn branch_registration_and_assignment_are_observable() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let owner = Address::generate(&env); + let id = asset_id(&env, 4); + let branch_id = asset_id(&env, 60); + + let branch = crate::branch::Branch { + id: branch_id.clone(), + name: soroban_sdk::String::from_str(&env, "North Depot"), + location: soroban_sdk::String::from_str(&env, "Lagos"), + admin: admin.clone(), + }; + client.register_branch(&branch); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "branch_registered"); + let emitted: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, branch_id); + + client.register_asset(&create_test_asset(&env, &owner, id.clone()), &admin); + client.assign_asset_to_branch(&branch_id, &id, &admin); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "asset_assigned_to_branch"); + let topic1: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + let topic2: BytesN<32> = topics.get(2).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic1, branch_id); + assert_eq!(topic2, id); +} + #[test] fn pause_and_unpause_emit_distinct_events() { let env = create_env(); From 5281cf75e68f01d7344b2c11922010b9b445acad Mon Sep 17 00:00:00 2001 From: khaadish Date: Sat, 17 Oct 2026 01:58:06 +0100 Subject: [PATCH 2/2] docs(audit): drop stray SC tag from reconciliation module docs --- contracts/assetsup/src/audit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/assetsup/src/audit.rs b/contracts/assetsup/src/audit.rs index c3854af82..12e7cee54 100644 --- a/contracts/assetsup/src/audit.rs +++ b/contracts/assetsup/src/audit.rs @@ -1,5 +1,5 @@ //! On-chain audit log and its reconciliation contract with the off-chain -//! `audit-logs` service ([SC-43]). +//! `audit-logs` service. //! //! ## What is written on-chain //!