Skip to content
Merged
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
1 change: 1 addition & 0 deletions contracts/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion contracts/assetsup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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]
Expand Down
28 changes: 28 additions & 0 deletions contracts/assetsup/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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>,
Expand Down
9 changes: 8 additions & 1 deletion contracts/assetsup/src/lease.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
35 changes: 35 additions & 0 deletions contracts/assetsup/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =====================
Expand Down Expand Up @@ -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<String>,
) -> 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<String> {
transfer_restrictions::get_address_jurisdiction(&env, address)
}

// =====================
// Detokenization
// =====================
Expand Down
50 changes: 49 additions & 1 deletion contracts/assetsup/src/tests/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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");
}
4 changes: 4 additions & 0 deletions contracts/assetsup/src/tests/pause.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions contracts/assetsup/src/tests/transfer_restrictions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
}
45 changes: 44 additions & 1 deletion contracts/assetsup/src/tests/ttl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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());
}
31 changes: 31 additions & 0 deletions contracts/assetsup/src/tokenization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>>(&holders_key) {
for holder in holders.iter() {
crate::ttl::extend_persistent(env, &TokenDataKey::TokenHolder(asset_id, holder));
}
}

Ok(())
}
Loading
Loading