diff --git a/contracts/EVENTS.md b/contracts/EVENTS.md index 8b63993c..3d14cfb6 100644 --- a/contracts/EVENTS.md +++ b/contracts/EVENTS.md @@ -109,6 +109,7 @@ assert_eq!(name, Symbol::new(&env, "asset_registered")); | `lease_returned` | `lease_id` | `caller`, `timestamp` | `return_leased_asset` | | `lease_cancelled` | `lease_id` | `caller`, `timestamp` | `cancel_lease` | | `lease_expired` | `lease_id` | `timestamp` | `expire_lease` | +| `lease_terminated_early` | `lease_id` | `caller`, `end_timestamp`, `timestamp` | `return_leased_asset` (before end date) | ### Insurance diff --git a/contracts/assetsup/README.md b/contracts/assetsup/README.md index 5a9b400e..9f856cb8 100644 --- a/contracts/assetsup/README.md +++ b/contracts/assetsup/README.md @@ -119,10 +119,15 @@ Reads: `get_asset`, `get_asset_info`, `batch_get_asset_info`, | `lock_tokens` | owner | | `unlock_tokens` | — | | `update_valuation` | — | +| `bump_asset_ttl` | — (permissionless keeper) | Reads: `get_token_balance`, `get_token_holders`, `is_tokens_locked`, `get_ownership_percentage`, `get_tokenized_asset`. +`bump_asset_ttl` extends the storage TTL of an asset's persistent entries so a +keeper can stop an inactive-but-valid asset being archived. It is idempotent +and exempt from the pause. + A token symbol may be claimed by at most one tokenized asset. Reusing a symbol is rejected with `DuplicateTokenSymbol` (`132`), which stops the same underlying asset being tokenized twice under different ids. @@ -148,7 +153,14 @@ tokenized, preventing immediate tokenize→detokenize round-trips. ### Transfer restrictions `set_transfer_restriction`, `add_to_whitelist`, `remove_from_whitelist`, -`is_whitelisted`, `get_whitelist`. None currently call `require_auth`. +`is_whitelisted`, `get_whitelist`, plus jurisdiction rules: +`set_geographic_restriction`, `set_address_jurisdiction`, +`get_address_jurisdiction`. None of the restriction setters currently call +`require_auth` except `set_address_jurisdiction`, which is self-authenticated. + +When an asset has a non-empty `geographic_allowed` allow-list, `validate_transfer` +requires the recipient to have a recorded jurisdiction that appears in it, or +fails with `GeographicRestriction`. ### Leasing and insurance @@ -181,6 +193,7 @@ threshold. | `("c_pause",)` | `pause_contract` | | `("c_unpause",)` | `unpause_contract` | | `("lease_new",)`, `("lease_ret",)`, `("lease_can",)`, `("lease_exp",)` | lease lifecycle | +| `lease_terminated_early` | `return_leased_asset` when returned before the end date | Tokenization, dividends, voting, and detokenization emit events too; see the respective modules. Registrar allowlist changes emit **no** event. See [SC-36] diff --git a/contracts/assetsup/src/events.rs b/contracts/assetsup/src/events.rs index 4b6843b3..e1c95de2 100644 --- a/contracts/assetsup/src/events.rs +++ b/contracts/assetsup/src/events.rs @@ -329,6 +329,19 @@ pub struct LeaseExpired { pub timestamp: u64, } +/// A lease was terminated early: the asset was returned before the lease's end +/// date. Distinct from `lease_returned` so consumers can tell a scheduled end +/// from a break. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaseTerminatedEarly { + #[topic] + pub lease_id: BytesN<32>, + pub caller: Address, + pub end_timestamp: u64, + pub timestamp: u64, +} + // --------------------------------------------------------------------------- // Insurance // --------------------------------------------------------------------------- @@ -658,6 +671,21 @@ pub fn lease_expired(env: &Env, lease_id: &BytesN<32>) { .publish(env); } +pub fn lease_terminated_early( + env: &Env, + lease_id: &BytesN<32>, + caller: &Address, + end_timestamp: u64, +) { + LeaseTerminatedEarly { + lease_id: lease_id.clone(), + caller: caller.clone(), + end_timestamp, + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + pub fn insurance_policy_expiry_reminder( env: &Env, policy_id: &BytesN<32>, diff --git a/contracts/assetsup/src/lease.rs b/contracts/assetsup/src/lease.rs index 64f80fd9..59299633 100644 --- a/contracts/assetsup/src/lease.rs +++ b/contracts/assetsup/src/lease.rs @@ -149,7 +149,14 @@ pub fn return_leased_asset(env: &Env, lease_id: BytesN<32>, caller: Address) -> save_lease(env, &lease); clear_asset_active_lease(env, &lease.asset_id); - crate::events::lease_returned(env, &lease_id, &caller); + // A return before the scheduled end date is an early termination and gets a + // distinct event, so off-chain consumers can tell it apart from a normal + // return. + if env.ledger().timestamp() < lease.end_timestamp { + crate::events::lease_terminated_early(env, &lease_id, &caller, lease.end_timestamp); + } else { + crate::events::lease_returned(env, &lease_id, &caller); + } Ok(()) } diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index af8df24d..1ef9e0d8 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -909,6 +909,13 @@ impl AssetUpContract { tokenization::update_valuation(&env, asset_id, new_valuation) } + /// Extend the storage TTL of a tokenized asset's persistent entries + /// (permissionless keeper path; idempotent) + pub fn bump_asset_ttl(env: Env, asset_id: u64) -> Result<(), Error> { + ttl::extend_instance(&env); + tokenization::bump_asset_ttl(&env, asset_id) + } + // ===================== // Dividend Functions // ===================== @@ -1032,6 +1039,34 @@ impl AssetUpContract { transfer_restrictions::get_whitelist(&env, asset_id) } + /// Set the jurisdiction allow-list for an asset's transfers + pub fn set_geographic_restriction( + env: Env, + asset_id: u64, + allowed_jurisdictions: Vec, + ) -> Result<(), Error> { + Self::require_not_paused(&env)?; + + transfer_restrictions::set_geographic_restriction(&env, asset_id, allowed_jurisdictions) + } + + /// Record the jurisdiction (ISO country code) of an address; self-authenticated + pub fn set_address_jurisdiction( + env: Env, + address: Address, + jurisdiction: String, + ) -> Result<(), Error> { + Self::require_not_paused(&env)?; + + address.require_auth(); + transfer_restrictions::set_address_jurisdiction(&env, address, jurisdiction) + } + + /// Get the recorded jurisdiction of an address + pub fn get_address_jurisdiction(env: Env, address: Address) -> Option { + transfer_restrictions::get_address_jurisdiction(&env, address) + } + // ===================== // Detokenization // ===================== diff --git a/contracts/assetsup/src/tests/events.rs b/contracts/assetsup/src/tests/events.rs index 93459b42..c12b5c89 100644 --- a/contracts/assetsup/src/tests/events.rs +++ b/contracts/assetsup/src/tests/events.rs @@ -6,7 +6,7 @@ //! //! See `contracts/EVENTS.md` for the catalogue these assertions pin down. -use soroban_sdk::testutils::{Address as _, Events as _}; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; use soroban_sdk::{Address, BytesN, Env, Symbol, TryIntoVal, Val, Vec}; use super::helpers::{create_env, create_test_asset, initialize_contract}; @@ -187,3 +187,51 @@ fn pause_and_unpause_emit_distinct_events() { let (topics, _) = only_event(&env); assert_event_name(&env, &topics, "contract_unpaused"); } + +#[test] +fn returning_a_lease_early_emits_a_distinct_event() { + // A break before the scheduled end date must be distinguishable from a + // normal return, which the old single `lease_returned` could not express. + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let lessor = Address::generate(&env); + let lessee = Address::generate(&env); + let asset_id = asset_id(&env, 4); + let lease_id = asset_id(&env, 5); + + client.create_lease( + &asset_id, &lease_id, &lessor, &lessee, &0u64, &1000u64, &100i128, &50i128, + ); + + // Ledger time is still 0, well before the end date of 1000. + client.return_leased_asset(&lease_id, &lessor); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "lease_terminated_early"); + let emitted: BytesN<32> = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(emitted, lease_id); +} + +#[test] +fn returning_a_lease_at_its_end_emits_lease_returned() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let lessor = Address::generate(&env); + let lessee = Address::generate(&env); + let asset_id = asset_id(&env, 6); + let lease_id = asset_id(&env, 7); + + client.create_lease( + &asset_id, &lease_id, &lessor, &lessee, &0u64, &1000u64, &100i128, &50i128, + ); + + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + client.return_leased_asset(&lease_id, &lessor); + + let (topics, _) = only_event(&env); + assert_event_name(&env, &topics, "lease_returned"); +} diff --git a/contracts/assetsup/src/tests/pause.rs b/contracts/assetsup/src/tests/pause.rs index a5e627dd..3b4492af 100644 --- a/contracts/assetsup/src/tests/pause.rs +++ b/contracts/assetsup/src/tests/pause.rs @@ -69,6 +69,10 @@ const PAUSE_EXEMPT: &[(&str, &str)] = &[ "storage_version", "read-only: returns the current storage layout version", ), + ( + "bump_asset_ttl", + "keeper path: extending TTL during a pause keeps valid entries from being archived", + ), ]; /// Reads never mutate, so they are not in scope for the pause. diff --git a/contracts/assetsup/src/tests/transfer_restrictions.rs b/contracts/assetsup/src/tests/transfer_restrictions.rs index c7baed2d..24e0e8ce 100644 --- a/contracts/assetsup/src/tests/transfer_restrictions.rs +++ b/contracts/assetsup/src/tests/transfer_restrictions.rs @@ -267,3 +267,81 @@ fn test_validate_transfer_accredited_required_uses_whitelist() { assert!(ok_result.is_ok()); assert!(err_result.is_err()); } + +#[test] +fn test_jurisdiction_restriction_allows_only_listed_jurisdictions() { + let env = Env::default(); + let contract_id = env.register(AssetUpContract, ()); + let tokenizer = Address::generate(&env); + let eu_holder = Address::generate(&env); + let us_holder = Address::generate(&env); + let unknown_holder = Address::generate(&env); + let asset_id = 904u64; + + let (eu_ok, us_err, unknown_err) = env.as_contract(&contract_id, || { + setup_tokenized_asset(&env, asset_id, &tokenizer); + + // Only German holders may receive this asset. + let allowed = soroban_sdk::vec![&env, String::from_str(&env, "DE")]; + transfer_restrictions::set_geographic_restriction(&env, asset_id, allowed).unwrap(); + + transfer_restrictions::set_address_jurisdiction( + &env, + eu_holder.clone(), + String::from_str(&env, "DE"), + ) + .unwrap(); + transfer_restrictions::set_address_jurisdiction( + &env, + us_holder.clone(), + String::from_str(&env, "US"), + ) + .unwrap(); + + let eu = transfer_restrictions::validate_transfer( + &env, + asset_id, + tokenizer.clone(), + eu_holder.clone(), + ); + let us = transfer_restrictions::validate_transfer( + &env, + asset_id, + tokenizer.clone(), + us_holder.clone(), + ); + // No jurisdiction on record: also blocked. + let unknown = transfer_restrictions::validate_transfer( + &env, + asset_id, + tokenizer.clone(), + unknown_holder.clone(), + ); + + (eu, us, unknown) + }); + + assert!(eu_ok.is_ok()); + assert!(us_err.is_err()); + assert!(unknown_err.is_err()); +} + +#[test] +fn test_jurisdiction_is_readable() { + let env = Env::default(); + let contract_id = env.register(AssetUpContract, ()); + let who = Address::generate(&env); + + let recorded = env.as_contract(&contract_id, || { + assert!(transfer_restrictions::get_address_jurisdiction(&env, who.clone()).is_none()); + transfer_restrictions::set_address_jurisdiction( + &env, + who.clone(), + String::from_str(&env, "JP"), + ) + .unwrap(); + transfer_restrictions::get_address_jurisdiction(&env, who.clone()) + }); + + assert_eq!(recorded, Some(String::from_str(&env, "JP"))); +} diff --git a/contracts/assetsup/src/tests/ttl.rs b/contracts/assetsup/src/tests/ttl.rs index 30ff2abe..f0299bb3 100644 --- a/contracts/assetsup/src/tests/ttl.rs +++ b/contracts/assetsup/src/tests/ttl.rs @@ -6,7 +6,7 @@ //! disappears from the contract's view. use soroban_sdk::testutils::{Address as _, Ledger as _, LedgerInfo}; -use soroban_sdk::{Address, BytesN, Env}; +use soroban_sdk::{Address, BytesN, Env, String}; use super::helpers::{create_env, create_test_asset, initialize_contract}; use crate::ttl::{LEDGERS_PER_DAY, PERSISTENT_EXTEND_TO}; @@ -114,3 +114,46 @@ fn contract_configuration_survives_past_the_default_lifetime() { assert!(!client.is_paused()); assert!(client.is_authorized_registrar(&admin)); } + +#[test] +fn bump_asset_ttl_keeps_an_inactive_tokenized_asset_alive() { + // Tokenization does not touch TTL on its own, so without the permissionless + // keeper path an asset that is held but never traded would be archived. + let env = create_env(); + let (client, _admin) = setup(&env); + let tokenizer = Address::generate(&env); + + client.tokenize_asset( + &1u64, + &String::from_str(&env, "KEEP"), + &1000i128, + &2u32, + &100i128, + &tokenizer, + &String::from_str(&env, "Keep Alive"), + &String::from_str(&env, "Inactive asset"), + &crate::types::AssetType::Digital, + ); + + // Two keeper hops. Each jump is longer than the default entry lifetime and + // leaves less than 30 days of headroom, so the next bump has work to do. + for _ in 0..2 { + client.bump_asset_ttl(&1u64); + advance_ledgers(&env, 70 * LEDGERS_PER_DAY); + } + + let tokenized = client + .get_tokenized_asset(&1u64) + .expect("a bumped asset must still be readable"); + assert_eq!(tokenized.total_supply, 1000); + // The holder balance entry must survive too, not just the asset record. + assert_eq!(client.get_token_balance(&1u64, &tokenizer).unwrap(), 1000); +} + +#[test] +fn bump_asset_ttl_rejects_an_asset_that_is_not_tokenized() { + let env = create_env(); + let (client, _admin) = setup(&env); + + assert!(client.try_bump_asset_ttl(&999u64).is_err()); +} diff --git a/contracts/assetsup/src/tokenization.rs b/contracts/assetsup/src/tokenization.rs index 5800df4e..91523fbf 100644 --- a/contracts/assetsup/src/tokenization.rs +++ b/contracts/assetsup/src/tokenization.rs @@ -477,3 +477,34 @@ pub fn update_valuation(env: &Env, asset_id: u64, new_valuation: i128) -> Result Ok(()) } + +/// Extend the storage TTL of every persistent entry that makes up a tokenized +/// asset: the asset record, its holders list and metadata, and each holder's +/// ownership record. +/// +/// Permissionless and idempotent, so an off-chain keeper can run it ahead of +/// expiry to keep inactive-but-still-valid assets from being archived. +pub fn bump_asset_ttl(env: &Env, asset_id: u64) -> Result<(), Error> { + let store = env.storage().persistent(); + + let asset_key = TokenDataKey::TokenizedAsset(asset_id); + if !store.has(&asset_key) { + return Err(Error::AssetNotTokenized); + } + + crate::ttl::extend_persistent(env, &asset_key); + crate::ttl::extend_persistent(env, &TokenDataKey::TokenMetadata(asset_id)); + + let holders_key = TokenDataKey::TokenHoldersList(asset_id); + crate::ttl::extend_persistent(env, &holders_key); + + // Extend each holder's ownership record too; otherwise the asset record + // survives while the balances quietly expire. + if let Some(holders) = store.get::<_, Vec
>(&holders_key) { + for holder in holders.iter() { + crate::ttl::extend_persistent(env, &TokenDataKey::TokenHolder(asset_id, holder)); + } + } + + Ok(()) +} diff --git a/contracts/assetsup/src/transfer_restrictions.rs b/contracts/assetsup/src/transfer_restrictions.rs index 5dcda1fa..6aca46cb 100644 --- a/contracts/assetsup/src/transfer_restrictions.rs +++ b/contracts/assetsup/src/transfer_restrictions.rs @@ -1,6 +1,6 @@ use crate::error::Error; use crate::types::{TokenDataKey, TransferRestriction}; -use soroban_sdk::{Address, Env, Vec}; +use soroban_sdk::{Address, Env, String, Vec}; /// Set transfer restrictions for an asset pub fn set_transfer_restriction( @@ -20,6 +20,53 @@ pub fn set_transfer_restriction( Ok(()) } +/// Update the jurisdiction allow-list for an asset, preserving the accredited +/// requirement. +/// +/// When the allow-list is non-empty, a transfer is only permitted to an address +/// whose recorded jurisdiction (see [`set_address_jurisdiction`]) appears in it. +pub fn set_geographic_restriction( + env: &Env, + asset_id: u64, + allowed_jurisdictions: Vec, +) -> Result<(), Error> { + let store = env.storage().persistent(); + + let key = TokenDataKey::TransferRestriction(asset_id); + let mut restriction: TransferRestriction = + store.get(&key).flatten().unwrap_or(TransferRestriction { + require_accredited: false, + geographic_allowed: Vec::new(env), + }); + + restriction.geographic_allowed = allowed_jurisdictions; + store.set(&key, &restriction); + + crate::events::restriction_set(env, asset_id, restriction.require_accredited); + + Ok(()) +} + +/// Record (or update) the jurisdiction of an address as an ISO country code. +pub fn set_address_jurisdiction( + env: &Env, + address: Address, + jurisdiction: String, +) -> Result<(), Error> { + env.storage() + .persistent() + .set(&TokenDataKey::AddressJurisdiction(address), &jurisdiction); + Ok(()) +} + +/// Read the recorded jurisdiction of an address, if any. +pub fn get_address_jurisdiction(env: &Env, address: Address) -> Option { + env.storage() + .persistent() + .get(&TokenDataKey::AddressJurisdiction(address)) + .flatten() +} + /// Add an address to the whitelist pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<(), Error> { let store = env.storage().persistent(); @@ -119,6 +166,22 @@ pub fn validate_transfer( } } + // Jurisdiction rules: if a non-empty allow-list is configured, `to` must + // have a recorded jurisdiction that appears in it. + if !restriction.geographic_allowed.is_empty() { + let jurisdiction: Option = store + .get(&TokenDataKey::AddressJurisdiction(to.clone())) + .flatten(); + let jurisdiction = jurisdiction.ok_or(Error::GeographicRestriction)?; + let allowed = restriction + .geographic_allowed + .iter() + .any(|j| j == jurisdiction); + if !allowed { + return Err(Error::GeographicRestriction); + } + } + Ok(true) } diff --git a/contracts/assetsup/src/types.rs b/contracts/assetsup/src/types.rs index cbf8f19f..0e819d33 100644 --- a/contracts/assetsup/src/types.rs +++ b/contracts/assetsup/src/types.rs @@ -103,6 +103,8 @@ pub enum TokenDataKey { DetokenizationProposal(u64), /// Stores TokenMetadata for asset_id TokenMetadata(u64), + /// Stores the recorded ISO country code (jurisdiction) for an address + AddressJurisdiction(Address), /// Maps a token symbol to the asset_id that claimed it, so the same /// underlying asset cannot be tokenized twice under a reused symbol. SymbolRegistry(String),